From 4a81609be2716b62a89d04b6d346e2c021d8d170 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Wed, 8 May 2019 10:56:49 +0200 Subject: [PATCH] spring cleaning (#32934) * reorganize views and forms * add copyright headers to all .py files * fix all style errors reported by flake8 --- src/authentic2/__init__.py | 16 + src/authentic2/a2_rbac/__init__.py | 16 + src/authentic2/a2_rbac/admin.py | 16 + src/authentic2/a2_rbac/app_settings.py | 20 +- src/authentic2/a2_rbac/apps.py | 20 +- src/authentic2/a2_rbac/fields.py | 17 + src/authentic2/a2_rbac/management.py | 22 +- src/authentic2/a2_rbac/managers.py | 19 +- src/authentic2/a2_rbac/models.py | 36 +- src/authentic2/a2_rbac/signal_handlers.py | 30 +- src/authentic2/a2_rbac/tests.py | 18 +- src/authentic2/a2_rbac/utils.py | 16 + src/authentic2/admin.py | 83 +- src/authentic2/api_urls.py | 35 +- src/authentic2/api_views.py | 9 +- src/authentic2/app_settings.py | 311 +++++--- src/authentic2/apps.py | 16 +- src/authentic2/attribute_aggregator/models.py | 0 src/authentic2/attribute_kinds.py | 30 +- src/authentic2/attributes_ng/engine.py | 32 +- .../attributes_ng/sources/__init__.py | 16 + .../sources/computed_targeted_id.py | 32 +- .../attributes_ng/sources/django_user.py | 18 +- .../attributes_ng/sources/format.py | 35 +- .../attributes_ng/sources/function.py | 33 +- src/authentic2/attributes_ng/sources/ldap.py | 20 + .../attributes_ng/sources/service_roles.py | 16 + .../auth2_auth/auth2_ssl/__init__.py | 22 +- src/authentic2/auth2_auth/auth2_ssl/admin.py | 17 + .../auth2_auth/auth2_ssl/app_settings.py | 38 +- .../auth2_auth/auth2_ssl/authenticators.py | 16 + .../auth2_auth/auth2_ssl/backends.py | 22 +- .../auth2_auth/auth2_ssl/middleware.py | 19 +- src/authentic2/auth2_auth/auth2_ssl/models.py | 17 + src/authentic2/auth2_auth/auth2_ssl/urls.py | 19 +- src/authentic2/auth2_auth/auth2_ssl/util.py | 28 +- src/authentic2/auth2_auth/auth2_ssl/views.py | 77 +- src/authentic2/auth2_auth/models.py | 0 src/authentic2/authentication.py | 16 + src/authentic2/authenticators.py | 21 +- src/authentic2/backends/__init__.py | 20 +- src/authentic2/backends/ldap_backend.py | 51 +- src/authentic2/backends/models_backend.py | 2 +- src/authentic2/cbv.py | 16 + src/authentic2/compat.py | 31 +- src/authentic2/compat_lasso.py | 16 + src/authentic2/constants.py | 16 + src/authentic2/context_processors.py | 20 +- src/authentic2/cors.py | 16 + src/authentic2/crypto.py | 23 +- src/authentic2/custom_user/__init__.py | 16 + src/authentic2/custom_user/apps.py | 16 + .../management/commands/changepassword.py | 24 +- .../management/commands/fix-attributes.py | 16 + src/authentic2/custom_user/managers.py | 26 +- src/authentic2/custom_user/models.py | 41 +- src/authentic2/data_transfer.py | 16 + src/authentic2/decorators.py | 74 +- .../disco_service/disco_responder.py | 80 +- src/authentic2/exponential_retry_timeout.py | 16 + src/authentic2/forms/__init__.py | 273 ------- src/authentic2/forms/authentication.py | 119 +++ src/authentic2/forms/fields.py | 16 + src/authentic2/forms/passwords.py | 128 ++++ src/authentic2/forms/profile.py | 168 ++++ .../forms.py => forms/registration.py} | 122 +-- src/authentic2/forms/utils.py | 33 + src/authentic2/forms/widgets.py | 42 +- src/authentic2/hashers.py | 43 +- src/authentic2/hooks.py | 20 +- src/authentic2/http_utils.py | 17 + src/authentic2/idp/interactions.py | 98 +-- .../idp/management/commands/cleanup.py | 11 - .../management/commands/cleanupauthentic.py | 23 +- src/authentic2/idp/middleware.py | 8 - src/authentic2/idp/models.py | 0 src/authentic2/idp/saml/__init__.py | 27 +- src/authentic2/idp/saml/app_settings.py | 53 +- src/authentic2/idp/saml/backend.py | 16 + src/authentic2/idp/saml/common.py | 25 +- src/authentic2/idp/saml/saml2_endpoints.py | 484 ++++++------ src/authentic2/idp/saml/urls.py | 16 + src/authentic2/idp/saml/views.py | 21 +- src/authentic2/idp/signals.py | 21 +- src/authentic2/idp/templatetags/__init__.py | 0 .../idp/templatetags/breadcrumbs.py | 134 ---- src/authentic2/idp/urls.py | 20 +- src/authentic2/idp/utils.py | 0 src/authentic2/idp/views.py | 0 src/authentic2/ldap_utils.py | 17 +- src/authentic2/log_filters.py | 19 +- src/authentic2/logger.py | 19 + .../commands/clean-unused-accounts.py | 78 +- .../management/commands/export_site.py | 17 +- .../management/commands/import_site.py | 16 + .../management/commands/load-ldif.py | 30 +- .../management/commands/resetpassword.py | 34 +- .../management/commands/slapd-shell.py | 20 +- .../management/commands/sync-ldap-users.py | 22 +- src/authentic2/manager/__init__.py | 16 + src/authentic2/manager/app_settings.py | 16 + src/authentic2/manager/apps.py | 17 +- src/authentic2/manager/fields.py | 16 + src/authentic2/manager/forms.py | 67 +- src/authentic2/manager/models.py | 0 src/authentic2/manager/ou_views.py | 16 + src/authentic2/manager/resources.py | 23 +- src/authentic2/manager/role_views.py | 53 +- src/authentic2/manager/service_views.py | 17 +- src/authentic2/manager/tables.py | 42 +- src/authentic2/manager/urls.py | 23 +- src/authentic2/manager/user_views.py | 39 +- src/authentic2/manager/utils.py | 16 +- src/authentic2/manager/views.py | 27 +- src/authentic2/manager/widgets.py | 16 + src/authentic2/managers.py | 17 +- src/authentic2/middleware.py | 27 +- src/authentic2/models.py | 173 ++--- src/authentic2/natural_key.py | 16 + src/authentic2/nonce/__init__.py | 16 + src/authentic2/nonce/models.py | 23 +- src/authentic2/nonce/utils.py | 41 +- src/authentic2/passwords.py | 18 +- src/authentic2/plugins.py | 39 +- src/authentic2/profile_forms.py | 38 - src/authentic2/profile_urls.py | 97 --- src/authentic2/profile_views.py | 127 ---- .../registration_backend/__init__.py | 0 src/authentic2/registration_backend/urls.py | 23 - src/authentic2/registration_backend/views.py | 416 ---------- src/authentic2/saml/__init__.py | 16 + src/authentic2/saml/admin.py | 138 ++-- src/authentic2/saml/admin_views.py | 25 +- src/authentic2/saml/app_settings.py | 34 +- src/authentic2/saml/common.py | 59 +- src/authentic2/saml/fields.py | 45 +- src/authentic2/saml/forms.py | 18 +- src/authentic2/saml/lasso_helper.py | 24 +- .../saml/management/commands/mapping.py | 16 + .../saml/management/commands/sync-metadata.py | 55 +- src/authentic2/saml/managers.py | 38 +- src/authentic2/saml/models.py | 381 +++++----- src/authentic2/saml/saml11utils.py | 210 ----- src/authentic2/saml/saml2utils.py | 122 +-- src/authentic2/saml/shibboleth/afp_parser.py | 20 + src/authentic2/saml/shibboleth/utils.py | 18 +- src/authentic2/saml/utils.py | 16 + src/authentic2/saml/x509utils.py | 83 +- src/authentic2/serializers.py | 20 +- src/authentic2/settings.py | 76 +- .../registration_completion_choose.html | 6 - .../registration_completion_form.html | 6 - .../registration/registration_form.html | 6 - src/authentic2/urls.py | 121 ++- src/authentic2/user_login_failure.py | 25 +- src/authentic2/utils.py | 49 +- src/authentic2/validators.py | 18 +- src/authentic2/views.py | 715 +++++++++++++++--- src/authentic2/widgets.py | 18 +- src/authentic2/wsgi.py | 21 +- src/authentic2_auth_oidc/__init__.py | 17 +- src/authentic2_auth_oidc/admin.py | 16 + src/authentic2_auth_oidc/app_settings.py | 22 +- src/authentic2_auth_oidc/authenticators.py | 16 + src/authentic2_auth_oidc/backends.py | 16 + .../commands/oidc-register-issuer.py | 19 +- src/authentic2_auth_oidc/managers.py | 16 + src/authentic2_auth_oidc/models.py | 16 + src/authentic2_auth_oidc/urls.py | 16 + src/authentic2_auth_oidc/utils.py | 28 +- src/authentic2_auth_oidc/views.py | 16 + src/authentic2_auth_saml/__init__.py | 17 + src/authentic2_auth_saml/adapters.py | 16 + src/authentic2_auth_saml/app_settings.py | 21 +- src/authentic2_auth_saml/authenticators.py | 16 + src/authentic2_auth_saml/backends.py | 16 + src/authentic2_auth_saml/models.py | 0 src/authentic2_auth_saml/urls.py | 16 + src/authentic2_auth_saml/views.py | 0 src/authentic2_idp_cas/__init__.py | 28 +- src/authentic2_idp_cas/admin.py | 81 +- src/authentic2_idp_cas/app_settings.py | 26 +- src/authentic2_idp_cas/constants.py | 16 + src/authentic2_idp_cas/managers.py | 19 +- src/authentic2_idp_cas/models.py | 74 +- src/authentic2_idp_cas/urls.py | 16 + src/authentic2_idp_cas/utils.py | 23 +- src/authentic2_idp_cas/views.py | 152 ++-- src/authentic2_idp_oidc/__init__.py | 17 +- src/authentic2_idp_oidc/admin.py | 16 + src/authentic2_idp_oidc/app_settings.py | 22 +- src/authentic2_idp_oidc/apps.py | 6 +- src/authentic2_idp_oidc/managers.py | 16 + src/authentic2_idp_oidc/models.py | 20 +- src/authentic2_idp_oidc/urls.py | 16 + src/authentic2_idp_oidc/utils.py | 17 +- src/authentic2_idp_oidc/views.py | 20 +- .../management/commands/provision.py | 48 +- .../tests/test_ldap.py | 46 +- src/django_rbac/backends.py | 1 + src/django_rbac/context_processors.py | 6 +- src/django_rbac/managers.py | 2 +- src/django_rbac/models.py | 21 +- src/django_rbac/test_settings.py | 11 +- src/django_rbac/utils.py | 18 +- tests/cache_urls.py | 16 + tests/conftest.py | 16 + tests/settings.py | 16 + tests/test_a2_rbac.py | 16 + tests/test_admin.py | 16 + tests/test_all.py | 16 + tests/test_api.py | 16 + tests/test_attribute_kinds.py | 16 + tests/test_auth_oidc.py | 16 + tests/test_auth_saml.py | 16 + tests/test_backends.py | 16 + tests/test_cas.py | 26 +- tests/test_change_email.py | 16 + tests/test_cleanup.py | 16 + tests/test_commands.py | 16 + tests/test_concurrency.py | 16 + tests/test_crypto.py | 16 + tests/test_custom_user.py | 16 + tests/test_customfields.py | 16 + tests/test_data_transfer.py | 16 + tests/test_hashers.py | 16 + tests/test_idp_oidc.py | 16 + tests/test_idp_saml2.py | 16 + tests/test_import_export_site_cmd.py | 16 + tests/test_ldap.py | 4 +- tests/test_login.py | 2 +- tests/test_manager.py | 15 + tests/test_natural_key.py | 15 + tests/test_ou_manager.py | 15 + tests/test_password_reset.py | 15 + tests/test_profile.py | 15 + tests/test_registration.py | 15 + tests/test_role_manager.py | 15 + tests/test_user_manager.py | 15 + tests/test_user_model.py | 5 +- tests/test_utils.py | 25 +- tests/test_views.py | 17 + tests/test_widget_datetimepicker.py | 17 + tests/utils.py | 17 + 244 files changed, 6197 insertions(+), 3306 deletions(-) delete mode 100644 src/authentic2/attribute_aggregator/models.py delete mode 100644 src/authentic2/auth2_auth/models.py create mode 100644 src/authentic2/forms/authentication.py create mode 100644 src/authentic2/forms/passwords.py create mode 100644 src/authentic2/forms/profile.py rename src/authentic2/{registration_backend/forms.py => forms/registration.py} (53%) create mode 100644 src/authentic2/forms/utils.py delete mode 100644 src/authentic2/idp/management/commands/cleanup.py delete mode 100644 src/authentic2/idp/middleware.py delete mode 100644 src/authentic2/idp/models.py delete mode 100644 src/authentic2/idp/templatetags/__init__.py delete mode 100644 src/authentic2/idp/templatetags/breadcrumbs.py delete mode 100644 src/authentic2/idp/utils.py delete mode 100644 src/authentic2/idp/views.py delete mode 100644 src/authentic2/manager/models.py delete mode 100644 src/authentic2/profile_forms.py delete mode 100644 src/authentic2/profile_urls.py delete mode 100644 src/authentic2/profile_views.py delete mode 100644 src/authentic2/registration_backend/__init__.py delete mode 100644 src/authentic2/registration_backend/urls.py delete mode 100644 src/authentic2/registration_backend/views.py delete mode 100644 src/authentic2/saml/saml11utils.py delete mode 100644 src/authentic2_auth_saml/models.py delete mode 100644 src/authentic2_auth_saml/views.py diff --git a/src/authentic2/__init__.py b/src/authentic2/__init__.py index 53bc83d2..c0ad2f29 100644 --- a/src/authentic2/__init__.py +++ b/src/authentic2/__init__.py @@ -1 +1,17 @@ +# 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 . + default_app_config = 'authentic2.apps.Authentic2Config' diff --git a/src/authentic2/a2_rbac/__init__.py b/src/authentic2/a2_rbac/__init__.py index 26c61532..fffc9b09 100644 --- a/src/authentic2/a2_rbac/__init__.py +++ b/src/authentic2/a2_rbac/__init__.py @@ -1 +1,17 @@ +# 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 . + default_app_config = 'authentic2.a2_rbac.apps.Authentic2RBACConfig' diff --git a/src/authentic2/a2_rbac/admin.py b/src/authentic2/a2_rbac/admin.py index 9f957151..822b8722 100644 --- a/src/authentic2/a2_rbac/admin.py +++ b/src/authentic2/a2_rbac/admin.py @@ -1,3 +1,19 @@ +# 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 . + from django.contrib import admin from django.utils.translation import ugettext_lazy as _ from django.utils import six diff --git a/src/authentic2/a2_rbac/app_settings.py b/src/authentic2/a2_rbac/app_settings.py index 37fd5540..daa2d04e 100644 --- a/src/authentic2/a2_rbac/app_settings.py +++ b/src/authentic2/a2_rbac/app_settings.py @@ -1,3 +1,22 @@ +# 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 sys + + class AppSettings(object): __DEFAULTS = dict( MANAGED_CONTENT_TYPES=None, @@ -21,7 +40,6 @@ class AppSettings(object): # Ugly? Guido recommends this himself ... # http://mail.python.org/pipermail/python-ideas/2012-May/014969.html -import sys app_settings = AppSettings('A2_RBAC_') app_settings.__name__ = __name__ sys.modules[__name__] = app_settings diff --git a/src/authentic2/a2_rbac/apps.py b/src/authentic2/a2_rbac/apps.py index 8fea5834..65cb2f00 100644 --- a/src/authentic2/a2_rbac/apps.py +++ b/src/authentic2/a2_rbac/apps.py @@ -1,3 +1,19 @@ +# 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 . + from django.apps import AppConfig @@ -7,9 +23,7 @@ class Authentic2RBACConfig(AppConfig): def ready(self): from . import signal_handlers, models - from django.db.models.signals import post_save, post_migrate, pre_save, \ - post_delete - from django.contrib.contenttypes.models import ContentType + from django.db.models.signals import post_save, post_migrate, post_delete from authentic2.models import Service # update rbac on save to contenttype, ou and roles diff --git a/src/authentic2/a2_rbac/fields.py b/src/authentic2/a2_rbac/fields.py index 58b7393c..b9282e66 100644 --- a/src/authentic2/a2_rbac/fields.py +++ b/src/authentic2/a2_rbac/fields.py @@ -1,6 +1,23 @@ +# 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 . + from django.db.models import NullBooleanField from django import forms + class UniqueBooleanField(NullBooleanField): '''BooleanField allowing only one True value in the table, and preventing problems with multiple False values by implicitely converting them to diff --git a/src/authentic2/a2_rbac/management.py b/src/authentic2/a2_rbac/management.py index b53bb998..4731c812 100644 --- a/src/authentic2/a2_rbac/management.py +++ b/src/authentic2/a2_rbac/management.py @@ -1,15 +1,29 @@ +# 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 . + from django.utils import six from django.utils.translation import ugettext_lazy as _ from django.utils.text import slugify from django.contrib.contenttypes.models import ContentType -from django.db.models.signals import post_migrate -from django.apps import apps from django_rbac.utils import get_role_model, get_ou_model, \ get_permission_model from ..utils import get_fk_model -from . import utils, app_settings, signal_handlers +from . import utils, app_settings def update_ou_admin_roles(ou): @@ -59,8 +73,6 @@ def update_ous_admin_roles(): they give general administrative rights to all mamanged content types scoped to the given organizational unit. ''' - Role = get_role_model() - Permission = get_permission_model() OU = get_ou_model() ou_all = OU.objects.all() if len(ou_all) < 2: diff --git a/src/authentic2/a2_rbac/managers.py b/src/authentic2/a2_rbac/managers.py index 72f9cbba..6fdce9cb 100644 --- a/src/authentic2/a2_rbac/managers.py +++ b/src/authentic2/a2_rbac/managers.py @@ -1,3 +1,19 @@ +# 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 . + from django.contrib.contenttypes.models import ContentType from django_rbac.models import ADMIN_OP @@ -60,7 +76,8 @@ class RoleManager(BaseRoleManager): defaults={ 'name': name, 'slug': slug, - }, **kwargs) + }, + **kwargs) if update_name and not created and role.name != name: role.name = name role.save() diff --git a/src/authentic2/a2_rbac/models.py b/src/authentic2/a2_rbac/models.py index 8c7717ff..aa99485f 100644 --- a/src/authentic2/a2_rbac/models.py +++ b/src/authentic2/a2_rbac/models.py @@ -1,3 +1,19 @@ +# 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 . + from collections import namedtuple from django.core.exceptions import ValidationError from django.utils import six @@ -5,7 +21,6 @@ from django.utils.translation import ugettext_lazy as _ from django.utils.text import slugify from django.db import models from django.contrib.contenttypes.models import ContentType -from django.core.validators import RegexValidator from django_rbac.models import (RoleAbstractBase, PermissionAbstractBase, OrganizationalUnitAbstractBase, RoleParentingAbstractBase, VIEW_OP, @@ -32,17 +47,17 @@ class OrganizationalUnit(OrganizationalUnitAbstractBase): MANUAL_PASSWORD_POLICY = 1 USER_ADD_PASSWD_POLICY_CHOICES = ( - (RESET_LINK_POLICY, _('Send reset link')), - (MANUAL_PASSWORD_POLICY, _('Manual password definition')), + (RESET_LINK_POLICY, _('Send reset link')), + (MANUAL_PASSWORD_POLICY, _('Manual password definition')), ) PolicyValue = namedtuple('PolicyValue', [ - 'generate_password', 'reset_password_at_next_login', - 'send_mail', 'send_password_reset']) + 'generate_password', 'reset_password_at_next_login', + 'send_mail', 'send_password_reset']) USER_ADD_PASSWD_POLICY_VALUES = { - RESET_LINK_POLICY: PolicyValue(False, False, False, True), - MANUAL_PASSWORD_POLICY: PolicyValue(False, False, True, False), + RESET_LINK_POLICY: PolicyValue(False, False, False, True), + MANUAL_PASSWORD_POLICY: PolicyValue(False, False, True, False), } username_is_unique = models.BooleanField( @@ -247,8 +262,11 @@ class Role(RoleAbstractBase): ) def natural_key(self): - return [self.slug, self.ou and self.ou.natural_key(), self.service and - self.service.natural_key()] + return [ + self.slug, + self.ou and self.ou.natural_key(), + self.service and self.service.natural_key(), + ] def to_json(self): return { diff --git a/src/authentic2/a2_rbac/signal_handlers.py b/src/authentic2/a2_rbac/signal_handlers.py index 8248b87b..87667d89 100644 --- a/src/authentic2/a2_rbac/signal_handlers.py +++ b/src/authentic2/a2_rbac/signal_handlers.py @@ -1,3 +1,19 @@ +# 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 . + from django.utils.translation import ugettext as _ from django.conf import settings from django.apps import apps @@ -8,6 +24,7 @@ from ..utils import get_fk_model from django_rbac.utils import get_ou_model, get_role_model, get_operation from django_rbac.managers import defer_update_transitive_closure + def create_default_ou(app_config, verbosity=2, interactive=True, using=DEFAULT_DB_ALIAS, **kwargs): if not router.allow_migrate(using, get_ou_model()): @@ -36,9 +53,7 @@ def create_default_ou(app_config, verbosity=2, interactive=True, def post_migrate_update_rbac(app_config, verbosity=2, interactive=True, using=DEFAULT_DB_ALIAS, **kwargs): # be sure new objects names are localized using the default locale - from .management import update_ou_admin_roles, update_ous_admin_roles, \ - update_content_types_roles - + from .management import update_ous_admin_roles, update_content_types_roles if not router.allow_migrate(using, get_role_model()): return @@ -50,16 +65,17 @@ def post_migrate_update_rbac(app_config, verbosity=2, interactive=True, def update_rbac_on_ou_post_save(sender, instance, created, raw, **kwargs): - from .management import update_ou_admin_roles, update_ous_admin_roles, \ - update_content_types_roles + from .management import update_ou_admin_roles, update_ous_admin_roles + if get_ou_model().objects.count() < 3 and created: update_ous_admin_roles() else: update_ou_admin_roles(instance) + def update_rbac_on_ou_post_delete(sender, instance, **kwargs): - from .management import update_ou_admin_roles, update_ous_admin_roles, \ - update_content_types_roles + from .management import update_ous_admin_roles + if get_ou_model().objects.count() < 2: update_ous_admin_roles() diff --git a/src/authentic2/a2_rbac/tests.py b/src/authentic2/a2_rbac/tests.py index 1b9356f9..b82898a4 100644 --- a/src/authentic2/a2_rbac/tests.py +++ b/src/authentic2/a2_rbac/tests.py @@ -1,3 +1,19 @@ +# 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 . + from django.test import TestCase from django.contrib.contenttypes.models import ContentType from django.contrib.auth import get_user_model @@ -10,7 +26,6 @@ Role = get_role_model() User = get_user_model() - class A2RBACTestCase(TestCase): def test_update_rbac(self): # 3 content types managers and 1 global manager @@ -73,7 +88,6 @@ class A2RBACTestCase(TestCase): self.assertTrue(role.slug.startswith('_a2'), u'role %s slug must ' 'start with _a2: %s' % (role.name, role.slug)) - def test_admin_roles_update_slug(self): user = User.objects.create(username='john.doe') name1 = 'Can manage john.doe' diff --git a/src/authentic2/a2_rbac/utils.py b/src/authentic2/a2_rbac/utils.py index a25a8a73..2931eb9f 100644 --- a/src/authentic2/a2_rbac/utils.py +++ b/src/authentic2/a2_rbac/utils.py @@ -1,3 +1,19 @@ +# 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 . + from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django_rbac.models import VIEW_OP, SEARCH_OP diff --git a/src/authentic2/admin.py b/src/authentic2/admin.py index 18e47dd6..8e28f728 100644 --- a/src/authentic2/admin.py +++ b/src/authentic2/admin.py @@ -1,3 +1,19 @@ +# 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 . + from copy import deepcopy import pprint @@ -5,26 +21,25 @@ from django.contrib import admin from django.conf import settings from django.utils.translation import ugettext_lazy as _ from django.utils import timezone -from django.utils.http import urlencode -from django.http import HttpResponseRedirect from django.views.decorators.cache import never_cache from django.contrib.auth.admin import UserAdmin from django.contrib.sessions.models import Session -from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.admin.utils import flatten_fieldsets from django import forms from django.contrib.auth.forms import ReadOnlyPasswordHashField from .nonce.models import Nonce -from . import (models, compat, app_settings, decorators, - attribute_kinds, utils) -from .forms import modelform_factory, BaseUserForm +from . import (models, app_settings, decorators, attribute_kinds, + utils) +from .forms.profile import BaseUserForm, modelform_factory from .custom_user.models import User + def cleanup_action(modeladmin, request, queryset): queryset.cleanup() cleanup_action.short_description = _('Cleanup expired objects') + class CleanupAdminMixin(admin.ModelAdmin): def get_actions(self, request): actions = super(CleanupAdminMixin, self).get_actions(request) @@ -32,16 +47,25 @@ class CleanupAdminMixin(admin.ModelAdmin): actions['cleanup_action'] = cleanup_action, 'cleanup_action', cleanup_action.short_description return actions + class NonceModelAdmin(admin.ModelAdmin): list_display = ("value", "context", "not_on_or_after") + admin.site.register(Nonce, NonceModelAdmin) + + class AttributeValueAdmin(admin.ModelAdmin): - list_display = ('content_type', 'owner', 'attribute', - 'content') + list_display = ('content_type', 'owner', 'attribute', 'content') + admin.site.register(models.AttributeValue, AttributeValueAdmin) + + class LogoutUrlAdmin(admin.ModelAdmin): list_display = ('provider', 'logout_url', 'logout_use_iframe', 'logout_use_iframe_timeout') + admin.site.register(models.LogoutUrl, LogoutUrlAdmin) + + class AuthenticationEventAdmin(admin.ModelAdmin): list_display = ('when', 'who', 'how', 'nonce') list_filter = ('how',) @@ -49,12 +73,17 @@ class AuthenticationEventAdmin(admin.ModelAdmin): search_fields = ('who', 'nonce', 'how') admin.site.register(models.AuthenticationEvent, AuthenticationEventAdmin) + + class UserExternalIdAdmin(admin.ModelAdmin): list_display = ('user', 'source', 'external_id', 'created', 'updated') list_filter = ('source',) date_hierarchy = 'created' search_fields = ('user__username', 'source', 'external_id') + admin.site.register(models.UserExternalId, UserExternalIdAdmin) + + class DeletedUserAdmin(admin.ModelAdmin): list_display = ('user', 'creation') date_hierarchy = 'creation' @@ -96,7 +125,7 @@ if settings.SESSION_ENGINE in DB_SESSION_ENGINES: backend = auth.load_backend(backend_class) try: user = backend.get_user(user_id) or auth_models.AnonymousUser() - except: + except Exception: user = _('deleted user %r') % user_id return user user.short_description = _('user') @@ -107,6 +136,7 @@ if settings.SESSION_ENGINE in DB_SESSION_ENGINES: admin.site.register(Session, SessionAdmin) + class ExternalUserListFilter(admin.SimpleListFilter): title = _('external') @@ -114,8 +144,8 @@ class ExternalUserListFilter(admin.SimpleListFilter): def lookups(self, request, model_admin): return ( - ('1', _('Yes')), - ('0', _('No')) + ('1', _('Yes')), + ('0', _('No')) ) def queryset(self, request, queryset): @@ -130,6 +160,7 @@ class ExternalUserListFilter(admin.SimpleListFilter): return queryset.filter(userexternalid__isnull=True) return queryset + class UserRealmListFilter(admin.SimpleListFilter): # Human-readable title which will be displayed in the # right admin sidebar just above the filter options. @@ -164,7 +195,8 @@ class UserChangeForm(BaseUserForm): 'missing_credential': _("You must at least give a username or an email to your user"), } - password = ReadOnlyPasswordHashField(label=_("Password"), + password = ReadOnlyPasswordHashField( + label=_("Password"), help_text=_("Raw passwords are not stored, so there is no way to see " "this user's password, but you can change the password " "using this form.")) @@ -192,6 +224,7 @@ class UserChangeForm(BaseUserForm): code='missing_credential', ) + class UserCreationForm(BaseUserForm): """ A form that creates a user, with no privileges, from the given username and @@ -201,9 +234,11 @@ class UserCreationForm(BaseUserForm): 'password_mismatch': _("The two password fields didn't match."), 'missing_credential': _("You must at least give a username or an email to your user"), } - password1 = forms.CharField(label=_("Password"), + password1 = forms.CharField( + label=_("Password"), widget=forms.PasswordInput) - password2 = forms.CharField(label=_("Password confirmation"), + password2 = forms.CharField( + label=_("Password confirmation"), widget=forms.PasswordInput, help_text=_("Enter the same password as above, for verification.")) @@ -235,6 +270,7 @@ class UserCreationForm(BaseUserForm): user.save() return user + class AuthenticUserAdmin(UserAdmin): fieldsets = ( (None, {'fields': ('uuid', 'ou', 'password')}), @@ -244,21 +280,19 @@ class AuthenticUserAdmin(UserAdmin): (_('Important dates'), {'fields': ('last_login', 'date_joined')}), ) add_fieldsets = ( - (None, { - 'classes': ('wide',), - 'fields': ('ou', 'username', 'first_name', 'last_name', 'email', 'password1', 'password2')} - ), - ) + (None, { + 'classes': ('wide',), + 'fields': ('ou', 'username', 'first_name', 'last_name', 'email', 'password1', 'password2')}), + ) readonly_fields = ('uuid',) - list_filter = UserAdmin.list_filter + (UserRealmListFilter,ExternalUserListFilter) + list_filter = UserAdmin.list_filter + (UserRealmListFilter, ExternalUserListFilter) list_display = ['__str__', 'ou', 'first_name', 'last_name', 'email'] def get_fieldsets(self, request, obj=None): fieldsets = deepcopy(super(AuthenticUserAdmin, self).get_fieldsets(request, obj)) if obj: if not request.user.is_superuser: - fieldsets[2][1]['fields'] = filter(lambda x: x != - 'is_superuser', fieldsets[2][1]['fields']) + fieldsets[2][1]['fields'] = filter(lambda x: x != 'is_superuser', fieldsets[2][1]['fields']) qs = models.Attribute.objects.all() insertion_idx = 2 else: @@ -292,6 +326,8 @@ class AuthenticUserAdmin(UserAdmin): kwargs['fields'] = fields return super(AuthenticUserAdmin, self).get_form(request, obj=obj, **kwargs) +admin.site.register(User, AuthenticUserAdmin) + class AttributeForm(forms.ModelForm): def __init__(self, *args, **kwargs): @@ -318,7 +354,6 @@ class AttributeAdmin(admin.ModelAdmin): def get_queryset(self, request): return self.model.all_objects.all() - admin.site.register(models.Attribute, AttributeAdmin) @@ -328,6 +363,7 @@ def login(request, extra_context=None): admin.site.login = login + @never_cache def logout(request, extra_context=None): return utils.redirect_to_login(request, login_url='auth_logout') @@ -335,4 +371,3 @@ def logout(request, extra_context=None): admin.site.logout = logout admin.site.register(models.PasswordReset) -admin.site.register(User, AuthenticUserAdmin) diff --git a/src/authentic2/api_urls.py b/src/authentic2/api_urls.py index a14aa2b5..a4c772d3 100644 --- a/src/authentic2/api_urls.py +++ b/src/authentic2/api_urls.py @@ -1,20 +1,31 @@ +# 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 . + from django.conf.urls import url from . import api_views urlpatterns = [ - url(r'^register/$', api_views.register, - name='a2-api-register'), - url(r'^password-change/$', api_views.password_change, - name='a2-api-password-change'), - url(r'^user/$', api_views.user, - name='a2-api-user'), - url(r'^roles/(?P[\w+]*)/members/(?P[^/]+)/$', - api_views.role_memberships, name='a2-api-role-member'), - url(r'^check-password/$', api_views.check_password, - name='a2-api-check-password'), - url(r'^validate-password/$', api_views.validate_password, - name='a2-api-validate-password'), + url(r'^register/$', api_views.register, name='a2-api-register'), + url(r'^password-change/$', api_views.password_change, name='a2-api-password-change'), + url(r'^user/$', api_views.user, name='a2-api-user'), + url(r'^roles/(?P[\w+]*)/members/(?P[^/]+)/$', api_views.role_memberships, + name='a2-api-role-member'), + url(r'^check-password/$', api_views.check_password, name='a2-api-check-password'), + url(r'^validate-password/$', api_views.validate_password, name='a2-api-validate-password'), ] urlpatterns += api_views.router.urls diff --git a/src/authentic2/api_views.py b/src/authentic2/api_views.py index 867bfeee..48f95cc4 100644 --- a/src/authentic2/api_views.py +++ b/src/authentic2/api_views.py @@ -1,5 +1,5 @@ # authentic2 - versatile identity manager -# Copyright (C) 2010-2018 Entr'ouvert +# 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 @@ -20,7 +20,6 @@ import smtplib from django.db import models from django.contrib.auth import get_user_model from django.core.exceptions import MultipleObjectsReturned -from django.utils import six from django.utils.translation import ugettext as _ from django.utils.encoding import force_text from django.views.decorators.vary import vary_on_headers @@ -138,8 +137,8 @@ class RegistrationSerializer(serializers.Serializer): User.objects.filter(ou=ou, email__iexact=data['email']).exists(): raise serializers.ValidationError( _('You already have an account')) - if (ou.username_is_unique and - 'username' not in data): + if (ou.username_is_unique + and 'username' not in data): raise serializers.ValidationError( _('Username is required in this ou')) if ou.username_is_unique and User.objects.filter( @@ -779,7 +778,6 @@ class CheckPasswordAPI(BaseRpcView): result['errors'] = [exc.detail] return result, status.HTTP_200_OK - check_password = CheckPasswordAPI.as_view() @@ -811,5 +809,4 @@ class ValidatePasswordAPI(BaseRpcView): result['ok'] = ok return result, status.HTTP_200_OK - validate_password = ValidatePasswordAPI.as_view() diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py index d21323a0..b8b37c82 100644 --- a/src/authentic2/app_settings.py +++ b/src/authentic2/app_settings.py @@ -1,3 +1,19 @@ +# 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 sys import six @@ -19,6 +35,7 @@ class Setting(object): def has_default(self): return self.default != self.SENTINEL + class AppSettings(object): def __init__(self, defaults): self.defaults = defaults @@ -35,6 +52,7 @@ class AppSettings(object): realms = {} if self.A2_REGISTRATION_REALM: realms[self.A2_REGISTRATION_REALM] = self.A2_REGISTRATION_REALM + def add_realms(new_realms): for realm in new_realms: if not isinstance(realm, (tuple, list)): @@ -68,120 +86,198 @@ class AppSettings(object): return getattr(self.settings, other_key) if self.defaults[key].has_default(): return self.defaults[key].default - raise ImproperlyConfigured('missing setting %s(%s) is mandatory' % - (key, self.defaults[key].description)) + raise ImproperlyConfigured( + 'missing setting %s(%s) is mandatory' % (key, self.defaults[key].description)) - -# Registration default_settings = dict( - ATTRIBUTE_BACKENDS = Setting( + ATTRIBUTE_BACKENDS=Setting( names=('A2_ATTRIBUTE_BACKENDS',), - default=('authentic2.attributes_ng.sources.format', - 'authentic2.attributes_ng.sources.function', - 'authentic2.attributes_ng.sources.django_user', - 'authentic2.attributes_ng.sources.ldap', - 'authentic2.attributes_ng.sources.computed_targeted_id', - 'authentic2.attributes_ng.sources.service_roles', + default=( + 'authentic2.attributes_ng.sources.format', + 'authentic2.attributes_ng.sources.function', + 'authentic2.attributes_ng.sources.django_user', + 'authentic2.attributes_ng.sources.ldap', + 'authentic2.attributes_ng.sources.computed_targeted_id', + 'authentic2.attributes_ng.sources.service_roles', ), definition='List of attribute backend classes or modules', ), - CAFILE = Setting(names=('AUTHENTIC2_CAFILE', 'CAFILE'), - default=None, - definition='File containing certificate chains as PEM certificates'), - A2_REGISTRATION_URLCONF = Setting(default='authentic2.registration_backend.urls', - definition='Root urlconf for the /accounts endpoints'), - A2_REGISTRATION_FORM_CLASS = Setting(default='authentic2.registration_backend.forms.RegistrationForm', - definition='Default registration form'), - A2_REGISTRATION_COMPLETION_FORM_CLASS = Setting(default='authentic2.registration_backend.forms.RegistrationCompletionForm', - definition='Default registration completion form'), - A2_REGISTRATION_SET_PASSWORD_FORM_CLASS = Setting(default='authentic2.registration_backend.forms.SetPasswordForm', - definition='Default set password form'), - A2_REGISTRATION_CHANGE_PASSWORD_FORM_CLASS = Setting(default='authentic2.registration_backend.forms.PasswordChangeForm', - definition='Default change password form'), - A2_REGISTRATION_CAN_DELETE_ACCOUNT = Setting(default=True, - definition='Can user self delete their account and all their data'), - A2_REGISTRATION_CAN_CHANGE_PASSWORD = Setting(default=True, definition='Allow user to change its own password'), - A2_REGISTRATION_EMAIL_BLACKLIST = Setting(default=[], definition='List of forbidden email ' - 'wildcards, ex.: ^.*@ville.fr$'), - A2_REGISTRATION_REDIRECT = Setting(default=None, definition='Forced redirection after each redirect, NEXT_URL ' - ' substring is replaced by the original next_url passed to /accounts/register/'), - A2_PROFILE_CAN_CHANGE_EMAIL = Setting(default=True, - definition='Can user self change their email'), - A2_PROFILE_CAN_EDIT_PROFILE = Setting(default=True, - definition='Can user self edit their profile'), - A2_PROFILE_CAN_MANAGE_FEDERATION = Setting(default=True, - definition='Can user manage its federations'), - A2_PROFILE_DISPLAY_EMPTY_FIELDS = Setting(default=False, - definition='Include empty fields in profile view'), - A2_HOMEPAGE_URL = Setting(default=None, definition='IdP has no homepage, ' - 'redirect to this one.'), - A2_USER_CAN_RESET_PASSWORD = Setting(default=None, definition='Allow online reset of passwords'), - A2_EMAIL_IS_UNIQUE = Setting(default=False, + CAFILE=Setting( + names=('AUTHENTIC2_CAFILE', 'CAFILE'), + default=None, + definition='File containing certificate chains as PEM certificates'), + A2_REGISTRATION_CAN_DELETE_ACCOUNT=Setting( + default=True, + definition='Can user self delete their account and all their data'), + A2_REGISTRATION_CAN_CHANGE_PASSWORD=Setting( + default=True, + definition='Allow user to change its own password'), + A2_REGISTRATION_EMAIL_BLACKLIST=Setting( + default=[], + definition='List of forbidden email wildcards, ex.: ^.*@ville.fr$'), + A2_REGISTRATION_REDIRECT=Setting( + default=None, + definition='Forced redirection after each redirect, NEXT_URL substring is replaced' + ' by the original next_url passed to /accounts/register/'), + A2_PROFILE_CAN_CHANGE_EMAIL=Setting( + default=True, + definition='Can user self change their email'), + A2_PROFILE_CAN_EDIT_PROFILE=Setting( + default=True, + definition='Can user self edit their profile'), + A2_PROFILE_CAN_MANAGE_FEDERATION=Setting( + default=True, + definition='Can user manage its federations'), + A2_PROFILE_DISPLAY_EMPTY_FIELDS=Setting( + default=False, + definition='Include empty fields in profile view'), + A2_HOMEPAGE_URL=Setting( + default=None, + definition='IdP has no homepage, redirect to this one.'), + A2_USER_CAN_RESET_PASSWORD=Setting( + default=None, + definition='Allow online reset of passwords'), + A2_EMAIL_IS_UNIQUE=Setting( + default=False, definition='Email of users must be unique'), - A2_REGISTRATION_EMAIL_IS_UNIQUE = Setting(default=False, + A2_REGISTRATION_EMAIL_IS_UNIQUE=Setting( + default=False, definition='Email of registererd accounts must be unique'), - A2_REGISTRATION_FORM_USERNAME_REGEX=Setting(default=r'^[\w.@+-]+$', definition='Regex to validate usernames'), - A2_REGISTRATION_FORM_USERNAME_HELP_TEXT=Setting(default=_('Required. At most ' - '30 characters. Letters, digits, and @/./+/-/_ only.')), - A2_REGISTRATION_FORM_USERNAME_LABEL=Setting(default=_('Username')), - A2_REGISTRATION_REALM=Setting(default=None, definition='Default realm to assign to self-registrated users'), - A2_REGISTRATION_GROUPS=Setting(default=(), definition='Default groups for self-registered users'), - A2_PROFILE_FIELDS=Setting(default=(), definition='Fields to show to the user in the profile page'), - A2_REGISTRATION_FIELDS=Setting(default=(), definition='Fields from the user model that must appear on the registration form'), - A2_REQUIRED_FIELDS=Setting(default=(), definition='User fields that are required'), - A2_REGISTRATION_REQUIRED_FIELDS=Setting(default=(), definition='Fields from the registration form that must be required'), - A2_PRE_REGISTRATION_FIELDS=Setting(default=(), definition='User fields to ask with email'), - A2_REALMS=Setting(default=(), definition='List of realms to search user accounts'), - A2_USERNAME_REGEX=Setting(default=None, definition='Regex that username must validate'), - A2_USERNAME_LABEL=Setting(default=None, definition='Alternate username label for the login' - ' form'), - A2_USERNAME_HELP_TEXT=Setting(default=None, definition='Help text to explain validation rules of usernames'), - A2_USERNAME_IS_UNIQUE=Setting(default=True, definition='Check username uniqueness'), - A2_LOGIN_FORM_OU_SELECTOR=Setting(default=False, definition='Whether to add an OU selector to the login form'), - A2_LOGIN_FORM_OU_SELECTOR_LABEL=Setting(default=None, definition='Label of OU field on login page'), - A2_REGISTRATION_USERNAME_IS_UNIQUE=Setting(default=True, definition='Check username uniqueness on registration'), + A2_REGISTRATION_FORM_USERNAME_REGEX=Setting( + default=r'^[\w.@+-]+$', + definition='Regex to validate usernames'), + A2_REGISTRATION_FORM_USERNAME_HELP_TEXT=Setting( + default=_('Required. At most 30 characters. Letters, digits, and @/./+/-/_ only.')), + A2_REGISTRATION_FORM_USERNAME_LABEL=Setting( + default=_('Username')), + A2_REGISTRATION_REALM=Setting( + default=None, + definition='Default realm to assign to self-registrated users'), + A2_REGISTRATION_GROUPS=Setting( + default=(), + definition='Default groups for self-registered users'), + A2_PROFILE_FIELDS=Setting( + default=(), + definition='Fields to show to the user in the profile page'), + A2_REGISTRATION_FIELDS=Setting( + default=(), + definition='Fields from the user model that must appear on the registration form'), + A2_REQUIRED_FIELDS=Setting( + default=(), + definition='User fields that are required'), + A2_REGISTRATION_REQUIRED_FIELDS=Setting( + default=(), + definition='Fields from the registration form that must be required'), + A2_PRE_REGISTRATION_FIELDS=Setting( + default=(), + definition='User fields to ask with email'), + A2_REALMS=Setting( + default=(), + definition='List of realms to search user accounts'), + A2_USERNAME_REGEX=Setting( + default=None, + definition='Regex that username must validate'), + A2_USERNAME_LABEL=Setting( + default=None, + definition='Alternate username label for the login form'), + A2_USERNAME_HELP_TEXT=Setting( + default=None, + definition='Help text to explain validation rules of usernames'), + A2_USERNAME_IS_UNIQUE=Setting( + default=True, + definition='Check username uniqueness'), + A2_LOGIN_FORM_OU_SELECTOR=Setting( + default=False, + definition='Whether to add an OU selector to the login form'), + A2_LOGIN_FORM_OU_SELECTOR_LABEL=Setting( + default=None, + definition='Label of OU field on login page'), + A2_REGISTRATION_USERNAME_IS_UNIQUE=Setting( + default=True, + definition='Check username uniqueness on registration'), IDP_BACKENDS=(), AUTH_FRONTENDS=(), AUTH_FRONTENDS_KWARGS={}, - VALID_REFERERS=Setting(default=(), definition='List of prefix to match referers'), - A2_OPENED_SESSION_COOKIE_NAME=Setting(default='A2_OPENED_SESSION', definition='Authentic session open'), - A2_OPENED_SESSION_COOKIE_DOMAIN=Setting(default=None), - A2_ATTRIBUTE_KINDS=Setting(default=(), definition='List of other attribute kinds'), - A2_ATTRIBUTE_KIND_PROFILE_IMAGE_SIZE=Setting(default=200, definition='Width and height for a profile image'), - A2_VALIDATE_EMAIL=Setting(default=False, definition='Validate user email server by doing an RCPT command'), - A2_VALIDATE_EMAIL_DOMAIN=Setting(default=True, definition='Validate user email domain'), - A2_PASSWORD_POLICY_MIN_CLASSES=Setting(default=3, definition='Minimum number of characters classes to be present in passwords'), - A2_PASSWORD_POLICY_MIN_LENGTH=Setting(default=8, definition='Minimum number of characters in a password'), - A2_PASSWORD_POLICY_REGEX=Setting(default=None, definition='Regular expression for validating passwords'), - A2_PASSWORD_POLICY_REGEX_ERROR_MSG=Setting(default=None, definition='Error message to show when the password do not validate the regular expression'), + VALID_REFERERS=Setting( + default=(), + definition='List of prefix to match referers'), + A2_OPENED_SESSION_COOKIE_NAME=Setting( + default='A2_OPENED_SESSION', + definition='Authentic session open'), + A2_OPENED_SESSION_COOKIE_DOMAIN=Setting( + default=None), + A2_ATTRIBUTE_KINDS=Setting( + default=(), + definition='List of other attribute kinds'), + A2_ATTRIBUTE_KIND_PROFILE_IMAGE_SIZE=Setting( + default=200, + definition='Width and height for a profile image'), + A2_VALIDATE_EMAIL=Setting( + default=False, + definition='Validate user email server by doing an RCPT command'), + A2_VALIDATE_EMAIL_DOMAIN=Setting( + default=True, + definition='Validate user email domain'), + A2_PASSWORD_POLICY_MIN_CLASSES=Setting( + default=3, + definition='Minimum number of characters classes to be present in passwords'), + A2_PASSWORD_POLICY_MIN_LENGTH=Setting( + default=8, + definition='Minimum number of characters in a password'), + A2_PASSWORD_POLICY_REGEX=Setting( + default=None, + definition='Regular expression for validating passwords'), + A2_PASSWORD_POLICY_REGEX_ERROR_MSG=Setting( + default=None, + definition='Error message to show when the password do not validate the regular expression'), A2_PASSWORD_POLICY_CLASS=Setting( default='authentic2.passwords.DefaultPasswordChecker', definition='path of a class to validate passwords'), - A2_PASSWORD_POLICY_SHOW_LAST_CHAR=Setting(default=False, definition='Show last character in password fields'), - A2_AUTH_PASSWORD_ENABLE=Setting(default=True, definition='Activate login/password authentication', names=('AUTH_PASSWORD',)), - A2_LOGIN_FAILURE_COUNT_BEFORE_WARNING=Setting(default=0, - definition='Failure count before logging a warning to ' - 'authentic2.user_login_failure. No warning will be send if value is ' - '0.'), - PUSH_PROFILE_UPDATES=Setting(default=False, definition='Push profile update to linked services'), - TEMPLATE_VARS=Setting(default={}, definition='Variable to pass to templates'), - A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR=Setting(default=1.8, - definition='exponential backoff factor duration as seconds until ' - 'next try after a login failure'), - A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION=Setting(default=0, - definition='exponential backoff base factor duration as secondss ' - 'until next try after a login failure'), - A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MAX_DURATION=Setting(default=3600, - definition='maximum exponential backoff maximum duration as seconds until ' - 'next try after a login failure'), - A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MIN_DURATION=Setting(default=10, - definition='minimum exponential backoff maximum duration as seconds until ' - 'next try after a login failure'), - A2_VERIFY_SSL=Setting(default=True, definition='Verify SSL certificate in HTTP requests'), - A2_ATTRIBUTE_KIND_TITLE_CHOICES=Setting(default=(), definition='Choices for the title attribute kind'), - A2_CORS_WHITELIST=Setting(default=(), definition='List of origin URL to whitelist, must be scheme://netloc[:port]'), - A2_EMAIL_CHANGE_TOKEN_LIFETIME=Setting(default=7200, definition='Lifetime in seconds of the ' - 'token sent to verify email adresses'), + A2_PASSWORD_POLICY_SHOW_LAST_CHAR=Setting( + default=False, + definition='Show last character in password fields'), + A2_AUTH_PASSWORD_ENABLE=Setting( + default=True, + definition='Activate login/password authentication', names=('AUTH_PASSWORD',)), + A2_LOGIN_FAILURE_COUNT_BEFORE_WARNING=Setting( + default=0, + definition='Failure count before logging a warning to ' + 'authentic2.user_login_failure. No warning will be send if value is ' + '0.'), + PUSH_PROFILE_UPDATES=Setting( + default=False, + definition='Push profile update to linked services'), + TEMPLATE_VARS=Setting( + default={}, + definition='Variable to pass to templates'), + A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR=Setting( + default=1.8, + definition='exponential backoff factor duration as seconds until ' + 'next try after a login failure'), + A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION=Setting( + default=0, + definition='exponential backoff base factor duration as secondss ' + 'until next try after a login failure'), + A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MAX_DURATION=Setting( + default=3600, + definition='maximum exponential backoff maximum duration as seconds until ' + 'next try after a login failure'), + A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MIN_DURATION=Setting( + default=10, + definition='minimum exponential backoff maximum duration as seconds until ' + 'next try after a login failure'), + A2_VERIFY_SSL=Setting( + default=True, + definition='Verify SSL certificate in HTTP requests'), + A2_ATTRIBUTE_KIND_TITLE_CHOICES=Setting( + default=(), + definition='Choices for the title attribute kind'), + A2_CORS_WHITELIST=Setting( + default=(), + definition='List of origin URL to whitelist, must be scheme://netloc[:port]'), + A2_EMAIL_CHANGE_TOKEN_LIFETIME=Setting( + default=7200, + definition='Lifetime in seconds of the token sent to verify email adresses'), A2_REDIRECT_WHITELIST=Setting( default=(), definition='List of origins which are authorized to ask for redirection.'), @@ -199,17 +295,22 @@ default_settings = dict( A2_USER_REMEMBER_ME=Setting( default=None, definition='Session duration as seconds when using the remember me ' - 'checkbox. Truthiness activates the checkbox.'), + 'checkbox. Truthiness activates the checkbox.'), A2_LOGIN_REDIRECT_AUTHENTICATED_USERS_TO_HOMEPAGE=Setting( default=False, definition='Redirect authenticated users to homepage'), A2_SET_RANDOM_PASSWORD_ON_RESET=Setting( default=True, definition='Set a random password on request to reset the password from the front-office'), - A2_ACCOUNTS_URL=Setting(default=None, definition='IdP has no account page, redirect to this one.'), - A2_CACHE_ENABLED=Setting(default=True, definition='Disable all cache decorators for testing purpose.'), - A2_ACCEPT_EMAIL_AUTHENTICATION=Setting(default=True, definition='Enable authentication by email'), - + A2_ACCOUNTS_URL=Setting( + default=None, + definition='IdP has no account page, redirect to this one.'), + A2_CACHE_ENABLED=Setting( + default=True, + definition='Disable all cache decorators for testing purpose.'), + A2_ACCEPT_EMAIL_AUTHENTICATION=Setting( + default=True, + definition='Enable authentication by email'), ) app_settings = AppSettings(default_settings) diff --git a/src/authentic2/apps.py b/src/authentic2/apps.py index e48060c7..0699a73e 100644 --- a/src/authentic2/apps.py +++ b/src/authentic2/apps.py @@ -1,3 +1,18 @@ +# 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 re from django.apps import AppConfig @@ -24,7 +39,6 @@ class Authentic2Config(AppConfig): else: expected_type = 'TEXT' - def convert_column_to_json(model, column_name): table_name = model._meta.db_table diff --git a/src/authentic2/attribute_aggregator/models.py b/src/authentic2/attribute_aggregator/models.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2/attribute_kinds.py b/src/authentic2/attribute_kinds.py index a8ff9cfc..6ae7680f 100644 --- a/src/authentic2/attribute_kinds.py +++ b/src/authentic2/attribute_kinds.py @@ -1,7 +1,22 @@ +# 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 re import string import datetime -import io import hashlib import os @@ -15,7 +30,6 @@ from django.utils.translation import ugettext_lazy as _, pgettext_lazy from django.utils.functional import allow_lazy from django.utils import html from django.template.defaultfilters import capfirst -from django.core.files import File from django.core.files.storage import default_storage from rest_framework import serializers @@ -65,8 +79,9 @@ class BirthdateRestField(serializers.DateField): def get_title_choices(): return app_settings.A2_ATTRIBUTE_KIND_TITLE_CHOICES or DEFAULT_TITLE_CHOICES -validate_phone_number = RegexValidator('^\+?\d{,20}$', message=_('Phone number can start with a + ' - 'an must contain only digits.')) +validate_phone_number = RegexValidator( + r'^\+?\d{,20}$', + message=_('Phone number can start with a + an must contain only digits.')) class PhoneNumberField(forms.CharField): @@ -77,7 +92,7 @@ class PhoneNumberField(forms.CharField): def clean(self, value): if value not in self.empty_values: - value = re.sub('[-.\s]', '', value) + value = re.sub(r'[-.\s]', '', value) validate_phone_number(value) return value @@ -87,7 +102,8 @@ class PhoneNumberDRFField(serializers.CharField): validate_fr_postcode = RegexValidator( - '^\d{5}$', message=_('The value must be a valid french postcode')) + r'^\d{5}$', + message=_('The value must be a valid french postcode')) class FrPostcodeField(forms.CharField): @@ -253,7 +269,7 @@ def only_digits(value): def validate_lun(value): - l = [(int(x) * (1 + i % 2)) for i, x in enumerate(reversed(value))] + l = [(int(x) * (1 + i % 2)) for i, x in enumerate(reversed(value))] # noqa: E741 return sum(x - 9 if x > 10 else x for x in l) % 10 == 0 diff --git a/src/authentic2/attributes_ng/engine.py b/src/authentic2/attributes_ng/engine.py index 23af87ea..09117d2f 100644 --- a/src/authentic2/attributes_ng/engine.py +++ b/src/authentic2/attributes_ng/engine.py @@ -1,6 +1,21 @@ +# 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 logging -from django.core.exceptions import ImproperlyConfigured from django.utils.translation import ugettext as _ from ..decorators import to_iter, to_list @@ -22,6 +37,7 @@ class UnsortableError(Exception): def __str__(self): return 'UnsortableError: %r' % self.unsortable_instances + def topological_sort(source_and_instances, ctx, raise_on_unsortable=False): ''' Sort instances topologically based on their dependency declarations. @@ -40,9 +56,9 @@ def topological_sort(source_and_instances, ctx, raise_on_unsortable=False): else: new_unsorted.append((source, instance)) unsorted = new_unsorted - if len(sorted_list) == len(source_and_instances): # finished ! + if len(sorted_list) == len(source_and_instances): # finished ! break - elif count_sorted == len(sorted_list): # no progress ! + elif count_sorted == len(sorted_list): # no progress ! if raise_on_unsortable: raise UnsortableError(sorted_list, unsorted) else: @@ -50,12 +66,12 @@ def topological_sort(source_and_instances, ctx, raise_on_unsortable=False): for source, instance in unsorted: dependencies = set(source.get_dependencies(instance, ctx)) sorted_list.append((source, instance)) - logger.debug('missing dependencies for instance %r of %r: %s', - instance, source, - list(dependencies-variables)) + logger.debug('missing dependencies for instance %r of %r: %s', instance, source, + list(dependencies - variables)) break return sorted_list + @to_list def get_sources(): ''' @@ -68,6 +84,7 @@ def get_sources(): for path in plugin.get_attribute_backends(): yield utils.import_module_or_class(path) + @to_list def get_attribute_names(ctx): ''' @@ -88,8 +105,7 @@ def get_attributes(ctx): ''' source_and_instances = [] for source in get_sources(): - source_and_instances.extend(((source, instance) for instance in - source.get_instances(ctx))) + source_and_instances.extend(((source, instance) for instance in source.get_instances(ctx))) source_and_instances = topological_sort(source_and_instances, ctx) ctx = ctx.copy() for source, instance in source_and_instances: diff --git a/src/authentic2/attributes_ng/sources/__init__.py b/src/authentic2/attributes_ng/sources/__init__.py index cd1e5f39..a06b262d 100644 --- a/src/authentic2/attributes_ng/sources/__init__.py +++ b/src/authentic2/attributes_ng/sources/__init__.py @@ -1,3 +1,19 @@ +# 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 abc from django.utils import six diff --git a/src/authentic2/attributes_ng/sources/computed_targeted_id.py b/src/authentic2/attributes_ng/sources/computed_targeted_id.py index d4abeb9a..2eaf6bd6 100644 --- a/src/authentic2/attributes_ng/sources/computed_targeted_id.py +++ b/src/authentic2/attributes_ng/sources/computed_targeted_id.py @@ -1,3 +1,19 @@ +# 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 . + ''' Compute a targeted id based on a hash of existing attributes, to compute a targetd id for a service provider and a user coming from an LDAP store using @@ -24,19 +40,17 @@ AUTHORIZED_KEYS = set(('name', 'label', 'source_attributes', 'salt', 'hash')) REQUIRED_KEYS = set(('name', 'source_attributes', 'salt')) -UNEXPECTED_KEYS_ERROR = \ - '{0}: unexpected key(s) {1} in configuration' -MISSING_KEYS_ERROR = \ - '{0}: missing key(s) {1} in configuration' -BAD_CONFIG_ERROR = \ - '{0}: template attribute source must contain a name, a list of dependencies and a function' -NOT_CALLABLE_ERROR = \ - '{0}: function attribute must be callable' +UNEXPECTED_KEYS_ERROR = '{0}: unexpected key(s) {1} in configuration' +MISSING_KEYS_ERROR = '{0}: missing key(s) {1} in configuration' +BAD_CONFIG_ERROR = '{0}: template attribute source must contain a name, a list of dependencies and a function' +NOT_CALLABLE_ERROR = '{0}: function attribute must be callable' SOURCE_ATTRIBUTE_TYPE_ERROR = '{0}: source_attributes must be a list of string' + def config_error(fmt, *args): raise ImproperlyConfigured(fmt.format(__name__, *args)) + @to_list def get_instances(ctx): ''' @@ -64,9 +78,11 @@ def get_attribute_names(instance, ctx): name = instance['name'] return ((name, instance.get('label', name)),) + def get_dependencies(instance, ctx): return instance['source_attributes'] + def get_attributes(instance, ctx): source_attributes = instance['source_attributes'] source_attributes_values = [] diff --git a/src/authentic2/attributes_ng/sources/django_user.py b/src/authentic2/attributes_ng/sources/django_user.py index 9ef4b083..07392d21 100644 --- a/src/authentic2/attributes_ng/sources/django_user.py +++ b/src/authentic2/attributes_ng/sources/django_user.py @@ -1,3 +1,20 @@ +# 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 . + +from django.contrib.auth import get_user_model from django.utils import six from django.utils.translation import ugettext_lazy as _ @@ -6,7 +23,6 @@ from django_rbac.utils import get_role_model from ...models import Attribute, AttributeValue from ...decorators import to_list -from ...compat import get_user_model @to_list diff --git a/src/authentic2/attributes_ng/sources/format.py b/src/authentic2/attributes_ng/sources/format.py index e8d390c4..dbe36118 100644 --- a/src/authentic2/attributes_ng/sources/format.py +++ b/src/authentic2/attributes_ng/sources/format.py @@ -1,3 +1,19 @@ +# 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 six from django.core.exceptions import ImproperlyConfigured @@ -6,30 +22,29 @@ from ...decorators import to_list AUTHORIZED_KEYS = set(('name', 'label', 'template')) + @to_list def get_field_refs(format_string): ''' Extract the base references from format_string ''' from string import Formatter - l = Formatter().parse(format_string) + l = Formatter().parse(format_string) # noqa: E741 for p in l: field_ref = p[1].split('[', 1)[0] field_ref = field_ref.split('.', 1)[0] yield field_ref -UNEXPECTED_KEYS_ERROR = \ - '{0}: unexpected ' 'key(s) {1} in configuration' -FORMAT_STRING_ERROR = \ - '{0}: template string must contain only keyword references: {1}' -BAD_CONFIG_ERROR = \ - 'template attribute source must contain a name and at least a template' -TYPE_ERROR = \ - 'template attribute must be a string' +UNEXPECTED_KEYS_ERROR = '{0}: unexpected ' 'key(s) {1} in configuration' +FORMAT_STRING_ERROR = '{0}: template string must contain only keyword references: {1}' +BAD_CONFIG_ERROR = 'template attribute source must contain a name and at least a template' +TYPE_ERROR = 'template attribute must be a string' + def config_error(fmt, *args): raise ImproperlyConfigured(fmt.format(__name__, *args)) + @to_list def get_instances(ctx): ''' @@ -54,8 +69,10 @@ def get_attribute_names(instance, ctx): name = instance['name'] return ((name, instance.get('label', name)),) + def get_dependencies(instance, ctx): return get_field_refs(instance['template']) + def get_attributes(instance, ctx): return {instance['name']: instance['template'].format(**ctx)} diff --git a/src/authentic2/attributes_ng/sources/function.py b/src/authentic2/attributes_ng/sources/function.py index e9f9d09c..ce47d1bb 100644 --- a/src/authentic2/attributes_ng/sources/function.py +++ b/src/authentic2/attributes_ng/sources/function.py @@ -1,3 +1,19 @@ +# 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 . + from django.core.exceptions import ImproperlyConfigured from ...decorators import to_list @@ -6,19 +22,17 @@ AUTHORIZED_KEYS = set(('name', 'label', 'dependencies', 'function')) REQUIRED_KEYS = set(('name', 'dependencies', 'function')) -UNEXPECTED_KEYS_ERROR = \ - '{0}: unexpected key(s) {1} in configuration' -MISSING_KEYS_ERROR = \ - '{0}: missing key(s) {1} in configuration' -BAD_CONFIG_ERROR = \ - '{0}: template attribute source must contain a name, a list of dependencies and a function' -NOT_CALLABLE_ERROR = \ - '{0}: function attribute must be callable' +UNEXPECTED_KEYS_ERROR = '{0}: unexpected key(s) {1} in configuration' +MISSING_KEYS_ERROR = '{0}: missing key(s) {1} in configuration' +BAD_CONFIG_ERROR = '{0}: template attribute source must contain a name, a list of dependencies and a function' +NOT_CALLABLE_ERROR = '{0}: function attribute must be callable' DEPENDENCY_TYPE_ERROR = '{0}: dependencies must be a list of string' + def config_error(fmt, *args): raise ImproperlyConfigured(fmt.format(__name__, *args)) + @to_list def get_instances(ctx): ''' @@ -40,7 +54,6 @@ def get_instances(ctx): not all(map(lambda x: isinstance(x, str), dependencies)): config_error(DEPENDENCY_TYPE_ERROR) - if not callable(d['function']): config_error(NOT_CALLABLE_ERROR) yield d @@ -50,9 +63,11 @@ def get_attribute_names(instance, ctx): name = instance['name'] return ((name, instance.get('label', name)),) + def get_dependencies(instance, ctx): return instance.get('dependencies', ()) + def get_attributes(instance, ctx): args = instance.get('args', ()) kwargs = instance.get('kwargs', {}) diff --git a/src/authentic2/attributes_ng/sources/ldap.py b/src/authentic2/attributes_ng/sources/ldap.py index 7d98ebf4..23de4b6e 100644 --- a/src/authentic2/attributes_ng/sources/ldap.py +++ b/src/authentic2/attributes_ng/sources/ldap.py @@ -1,7 +1,24 @@ +# 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 . + from ...decorators import to_list from authentic2.backends.ldap_backend import LDAPBackend, LDAPUser + @to_list def get_instances(ctx): ''' @@ -9,12 +26,15 @@ def get_instances(ctx): ''' return [None] + def get_attribute_names(instance, ctx): return LDAPBackend.get_attribute_names() + def get_dependencies(instance, ctx): return ('user',) + def get_attributes(instance, ctx): user = ctx.get('user') if user and isinstance(user, LDAPUser): diff --git a/src/authentic2/attributes_ng/sources/service_roles.py b/src/authentic2/attributes_ng/sources/service_roles.py index 4d1a079f..cc5ef5d1 100644 --- a/src/authentic2/attributes_ng/sources/service_roles.py +++ b/src/authentic2/attributes_ng/sources/service_roles.py @@ -1,3 +1,19 @@ +# 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 . + from django.utils.translation import ugettext_lazy as _ from ...models import Service diff --git a/src/authentic2/auth2_auth/auth2_ssl/__init__.py b/src/authentic2/auth2_auth/auth2_ssl/__init__.py index b62b71a8..e585021f 100644 --- a/src/authentic2/auth2_auth/auth2_ssl/__init__.py +++ b/src/authentic2/auth2_auth/auth2_ssl/__init__.py @@ -1,3 +1,20 @@ +# 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 . + + class Plugin(object): def get_before_urls(self): from . import app_settings @@ -5,9 +22,8 @@ class Plugin(object): from authentic2.decorators import setting_enabled, required return required( - setting_enabled('ENABLE', settings=app_settings), - [ - url(r'^accounts/sslauth/', include(__name__ + '.urls'))]) + setting_enabled('ENABLE', settings=app_settings), + [url(r'^accounts/sslauth/', include(__name__ + '.urls'))]) def get_apps(self): return [__name__] diff --git a/src/authentic2/auth2_auth/auth2_ssl/admin.py b/src/authentic2/auth2_auth/auth2_ssl/admin.py index 2e47f729..dd0767aa 100644 --- a/src/authentic2/auth2_auth/auth2_ssl/admin.py +++ b/src/authentic2/auth2_auth/auth2_ssl/admin.py @@ -1,7 +1,24 @@ +# 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 . + from django.contrib import admin from . import models + class ClientCertificateAdmin(admin.ModelAdmin): list_display = ('user', 'subject_dn', 'issuer_dn', 'serial') diff --git a/src/authentic2/auth2_auth/auth2_ssl/app_settings.py b/src/authentic2/auth2_auth/auth2_ssl/app_settings.py index 5ff159e9..24911d44 100644 --- a/src/authentic2/auth2_auth/auth2_ssl/app_settings.py +++ b/src/authentic2/auth2_auth/auth2_ssl/app_settings.py @@ -1,4 +1,18 @@ -# -*- coding: utf-8 -*- +# 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 sys @@ -6,16 +20,16 @@ import sys class AppSettings(object): '''Thanks django-allauth''' __DEFAULTS = dict( - # settings for TEST only, make it easy to simulate the SSL - # environment - ENABLE=False, - FORCE_ENV={}, - ACCEPT_SELF_SIGNED=False, - STRICT_MATCH=False, - SUBJECT_MATCH_KEYS=('subject_dn', 'issuer_dn'), - CREATE_USERNAME_CALLBACK=None, - USE_COOKIE=False, - CREATE_USER=False, + # settings for TEST only, make it easy to simulate the SSL + # environment + ENABLE=False, + FORCE_ENV={}, + ACCEPT_SELF_SIGNED=False, + STRICT_MATCH=False, + SUBJECT_MATCH_KEYS=('subject_dn', 'issuer_dn'), + CREATE_USERNAME_CALLBACK=None, + USE_COOKIE=False, + CREATE_USER=False, ) def __init__(self, prefix): @@ -23,7 +37,7 @@ class AppSettings(object): def _setting(self, name, dflt): from django.conf import settings - return getattr(settings, self.prefix+name, dflt) + return getattr(settings, self.prefix + name, dflt) def __getattr__(self, name): if name not in self.__DEFAULTS: diff --git a/src/authentic2/auth2_auth/auth2_ssl/authenticators.py b/src/authentic2/auth2_auth/auth2_ssl/authenticators.py index 5332b93b..ee7a7709 100644 --- a/src/authentic2/auth2_auth/auth2_ssl/authenticators.py +++ b/src/authentic2/auth2_auth/auth2_ssl/authenticators.py @@ -1,3 +1,19 @@ +# 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 . + from django.utils.translation import ugettext_lazy as _ import django.forms diff --git a/src/authentic2/auth2_auth/auth2_ssl/backends.py b/src/authentic2/auth2_auth/auth2_ssl/backends.py index 2753ac2b..13627031 100644 --- a/src/authentic2/auth2_auth/auth2_ssl/backends.py +++ b/src/authentic2/auth2_auth/auth2_ssl/backends.py @@ -1,13 +1,31 @@ +# 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 . + +from django.contrib.auth import get_user_model from django.db.models import Q import logging -from authentic2.compat import get_user_model from authentic2.backends import is_user_authenticable from . import models, app_settings logger = logging.getLogger(__name__) +User = get_user_model() + class AuthenticationError(Exception): pass @@ -39,7 +57,6 @@ class SSLBackend: simply return the user object. That way, we only need top look-up the certificate once, when loggin in """ - User = get_user_model() try: return User.objects.get(id=user_id) except User.DoesNotExist: @@ -80,7 +97,6 @@ class SSLBackend: just a subject for the ClientCertificate. """ # auto creation only created a DN for the subject, not the issuer - User = get_user_model() # get username and check if the user exists already if app_settings.CREATE_USERNAME_CALLBACK: diff --git a/src/authentic2/auth2_auth/auth2_ssl/middleware.py b/src/authentic2/auth2_auth/auth2_ssl/middleware.py index 74ac85dc..f8a3a958 100644 --- a/src/authentic2/auth2_auth/auth2_ssl/middleware.py +++ b/src/authentic2/auth2_auth/auth2_ssl/middleware.py @@ -1,5 +1,20 @@ -from django.contrib.auth import authenticate, login +# 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 . +from django.contrib.auth import authenticate, login from . import util, app_settings @@ -11,7 +26,7 @@ class SSLAuthMiddleware(object): def process_request(self, request): if app_settings.USE_COOKIE and request.user.is_authenticated(): return - ssl_info = util.SSLInfo(request) + ssl_info = util.SSLInfo(request) user = authenticate(ssl_info=ssl_info) if user and request.user != user: login(request, user) diff --git a/src/authentic2/auth2_auth/auth2_ssl/models.py b/src/authentic2/auth2_auth/auth2_ssl/models.py index c6b582fc..0057371d 100644 --- a/src/authentic2/auth2_auth/auth2_ssl/models.py +++ b/src/authentic2/auth2_auth/auth2_ssl/models.py @@ -1,9 +1,26 @@ +# 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 . + from django.db import models from django.conf import settings from django.utils import six from . import util + @six.python_2_unicode_compatible class ClientCertificate(models.Model): serial = models.CharField(max_length=255, blank=True) diff --git a/src/authentic2/auth2_auth/auth2_ssl/urls.py b/src/authentic2/auth2_auth/auth2_ssl/urls.py index 0220756c..1b63ac4f 100644 --- a/src/authentic2/auth2_auth/auth2_ssl/urls.py +++ b/src/authentic2/auth2_auth/auth2_ssl/urls.py @@ -1,6 +1,21 @@ +# 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 . + from django.conf.urls import url -from .views import (handle_request, post_account_linking, delete_certificate, - error_ssl) +from .views import (handle_request, post_account_linking, delete_certificate, error_ssl) urlpatterns = [ url(r'^$', diff --git a/src/authentic2/auth2_auth/auth2_ssl/util.py b/src/authentic2/auth2_auth/auth2_ssl/util.py index 9bf3eb22..0302fed7 100644 --- a/src/authentic2/auth2_auth/auth2_ssl/util.py +++ b/src/authentic2/auth2_auth/auth2_ssl/util.py @@ -1,3 +1,19 @@ +# 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 base64 import six @@ -11,25 +27,28 @@ X509_KEYS = { 'verify': 'SSL_CLIENT_VERIFY', } + def normalize_cert(certificate_pem): '''Normalize content of the certificate''' base64_content = ''.join(certificate_pem.splitlines()[1:-1]) content = base64.b64decode(base64_content) return base64.b64encode(content) + def explode_dn(dn): '''Extract sub element of a DN as displayed by mod_ssl or nginx_ssl''' dn = dn.strip('/') parts = dn.split('/') parts = [part.split('=') for part in parts] - parts = [(part[0], part[1].decode('string_escape').decode('utf-8')) - for part in parts] + parts = [(part[0], part[1].decode('string_escape').decode('utf-8')) for part in parts] return parts + TRANSFORM = { - 'cert': normalize_cert, + 'cert': normalize_cert, } + class SSLInfo(object): """ Encapsulates the SSL environment variables in a read-only object. It @@ -48,7 +67,7 @@ class SSLInfo(object): else: raise EnvironmentError('The SSL authentication currently only \ works with mod_python or wsgi requests') - self.read_env(env); + self.read_env(env) pass def read_env(self, env): @@ -64,7 +83,6 @@ class SSLInfo(object): else: self.__dict__[attr] = None - if self.__dict__['verify'] == 'SUCCESS': self.__dict__['verify'] = True else: diff --git a/src/authentic2/auth2_auth/auth2_ssl/views.py b/src/authentic2/auth2_auth/auth2_ssl/views.py index 7ca8d519..30540fa4 100644 --- a/src/authentic2/auth2_auth/auth2_ssl/views.py +++ b/src/authentic2/auth2_auth/auth2_ssl/views.py @@ -1,3 +1,19 @@ +# 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 logging from django.utils.translation import ugettext as _ @@ -16,24 +32,21 @@ from . import models, util, app_settings logger = logging.getLogger(__name__) + def handle_request(request): # Check certificate validity - ssl_info = util.SSLInfo(request) + ssl_info = util.SSLInfo(request) accept_self_signed = app_settings.ACCEPT_SELF_SIGNED if not ssl_info.cert: - logger.error('SSL Client Authentication failed: ' - 'SSL CGI variable CERT is missing') + logger.error('SSL Client Authentication failed: SSL CGI variable CERT is missing') messages.add_message(request, messages.ERROR, - _('SSL Client Authentication failed. ' - 'No client certificate found.')) + _('SSL Client Authentication failed. No client certificate found.')) return redirect_to_login(request) elif not accept_self_signed and not ssl_info.verify: - logger.error('SSL Client Authentication failed: ' - 'SSL CGI variable VERIFY is not SUCCESS') + logger.error('SSL Client Authentication failed: SSL CGI variable VERIFY is not SUCCESS') messages.add_message(request, messages.ERROR, - _('SSL Client Authentication failed. ' - 'Your client certificate is not valid.')) + _('SSL Client Authentication failed. Your client certificate is not valid.')) return redirect_to_login(request) # SSL entries for this certificate? @@ -51,7 +64,7 @@ def handle_request(request): else: logger.error('account creation failure') messages.add_message(request, messages.ERROR, - _('SSL Client Authentication failed. Internal server error.')) + _('SSL Client Authentication failed. Internal server error.')) return redirect_to_login(request) # No SSL entries and no user session, redirect account linking page @@ -61,14 +74,12 @@ def handle_request(request): # No SSL entries but active user session, perform account linking if not user and request.user.is_authenticated(): from backend import SSLBackend - if SSLBackend().link_user(ssl_info, request.user): - logger.info('Successful linking of the SSL ' - 'Certificate to an account, redirection to %s' % next_url) - else: + if not SSLBackend().link_user(ssl_info, request.user): logger.error('login() failed') messages.add_message(request, messages.ERROR, - _('SSL Client Authentication failed. Internal server error.')) + _('SSL Client Authentication failed. Internal server error.')) return redirect_to_login(request) + logger.info('Successful linking of the SSL Certificate to an account') # SSL Entries found for this certificate, # if the user is logged out, we login @@ -81,56 +92,40 @@ def handle_request(request): # check that the SSL entry for the certificate is this user. # else, we make this certificate point on that user. if user.username != request.user.username: - logger.warning(u'The certificate belongs to %s, ' - 'but %s is logged with, we change the association!', - user, request.user) + logger.warning(u'The certificate belongs to %s, but %s is logged with, we change the association!', + user, request.user) from backends import SSLBackend cert = SSLBackend().get_certificate(ssl_info) cert.user = request.user cert.save() return continue_to_next_url(request) -### - # post_account_linking - # @request - # - # Called after an account linking. - ### + @csrf_exempt def post_account_linking(request): - logger.info('auth2_ssl Return after account linking form filled') if request.method == "POST": - if 'do_creation' in request.POST \ - and request.POST['do_creation'] == 'on': - logger.info('account creation asked') + if 'do_creation' in request.POST and request.POST['do_creation'] == 'on': request.session['do_creation'] = 'do_creation' return redirect_to_login(request, login_url='user_signin_ssl') form = AuthenticationForm(data=request.POST) if form.is_valid(): - logger.info('form valid') user = form.get_user() - try: - login(request, user) - record_authentication_event(request, how='password') - except: - logger.error('login() failed') - messages.add_message(request, messages.ERROR, - _('SSL Client Authentication failed. Internal server error.')) - - logger.debug('session opened') + login(request, user) + record_authentication_event(request, how='password') return redirect_to_login(request, login_url='user_signin_ssl') else: - logger.warning('form not valid - Try again! (Brute force?)') return render(request, 'auth/account_linking_ssl.html') else: return render(request, 'auth/account_linking_ssl.html') + def profile(request, template_name='ssl/profile.html', *args, **kwargs): context = kwargs.pop('context', {}) certificates = models.ClientCertificate.objects.filter(user=request.user) context.update({'certificates': certificates}) return render_to_string(template_name, context, request=request) + def delete_certificate(request, certificate_pk): qs = models.ClientCertificate.objects.filter(pk=certificate_pk) count = qs.count() @@ -138,8 +133,8 @@ def delete_certificate(request, certificate_pk): if count: logger.info('client certificate %s deleted', certificate_pk) messages.info(request, _('Certificate deleted.')) - return redirect(request, 'account_management', - fragment='a2-ssl-certificate-profile') + return redirect(request, 'account_management', fragment='a2-ssl-certificate-profile') + class SslErrorView(TemplateView): template_name = 'error_ssl.html' diff --git a/src/authentic2/auth2_auth/models.py b/src/authentic2/auth2_auth/models.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2/authentication.py b/src/authentic2/authentication.py index 38cadf45..15b661c9 100644 --- a/src/authentic2/authentication.py +++ b/src/authentic2/authentication.py @@ -1,3 +1,19 @@ +# 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 . + from authentic2_idp_oidc.models import OIDCClient from rest_framework.exceptions import AuthenticationFailed diff --git a/src/authentic2/authenticators.py b/src/authentic2/authenticators.py index 0944d7b6..560230dc 100644 --- a/src/authentic2/authenticators.py +++ b/src/authentic2/authenticators.py @@ -1,7 +1,24 @@ +# 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 . + from django.shortcuts import render from django.utils.translation import ugettext as _, ugettext_lazy -from . import views, app_settings, utils, constants, forms +from . import views, app_settings, utils, constants +from .forms import authentication as authentication_forms class LoginPasswordAuthenticator(object): @@ -20,7 +37,7 @@ class LoginPasswordAuthenticator(object): context = kwargs.get('context', {}) is_post = request.method == 'POST' and self.submit_name in request.POST data = request.POST if is_post else None - form = forms.AuthenticationForm(request=request, data=data) + form = authentication_forms.AuthenticationForm(request=request, data=data) if app_settings.A2_ACCEPT_EMAIL_AUTHENTICATION: form.fields['username'].label = _('Username or email') if app_settings.A2_USERNAME_LABEL: diff --git a/src/authentic2/backends/__init__.py b/src/authentic2/backends/__init__.py index 4d5e5547..43f4c267 100644 --- a/src/authentic2/backends/__init__.py +++ b/src/authentic2/backends/__init__.py @@ -1,3 +1,19 @@ +# 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 . + from django.contrib.auth import get_user_model from authentic2 import app_settings @@ -25,5 +41,5 @@ def is_user_authenticable(user): return get_user_queryset().filter(pk=user.pk).exists() -from .ldap_backend import LDAPBackend -from .models_backend import ModelBackend +from .ldap_backend import LDAPBackend # noqa: F401 +from .models_backend import ModelBackend # noqa: F401 diff --git a/src/authentic2/backends/ldap_backend.py b/src/authentic2/backends/ldap_backend.py index b05ea98d..47485c79 100644 --- a/src/authentic2/backends/ldap_backend.py +++ b/src/authentic2/backends/ldap_backend.py @@ -1,3 +1,19 @@ +# 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 . + try: import ldap import ldap.modlist @@ -19,23 +35,19 @@ import os # code originaly copied from by now merely inspired by # http://www.amherst.k12.oh.us/django-ldap.html -log = logging.getLogger(__name__) - from django.core.exceptions import ImproperlyConfigured from django.conf import settings +from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.utils.encoding import force_bytes, force_text from django.utils import six from django.utils.six.moves.urllib import parse as urlparse -from django.utils import six from authentic2.a2_rbac.models import Role from authentic2.compat_lasso import lasso from authentic2 import crypto, app_settings -from authentic2.decorators import to_list -from authentic2.compat import get_user_model from authentic2.models import UserExternalId from authentic2.middleware import StoreRequestMiddleware from authentic2.user_login_failure import user_login_failure, user_login_success @@ -46,6 +58,9 @@ from authentic2.utils import utf8_encode, to_list from authentic2.backends import is_user_authenticable +log = logging.getLogger(__name__) + +User = get_user_model() DEFAULT_CA_BUNDLE = '' @@ -212,7 +227,7 @@ def map_text(d): raise NotImplementedError -class LDAPUser(get_user_model()): +class LDAPUser(User): SESSION_LDAP_DATA_KEY = 'ldap-data' _changed = False @@ -261,12 +276,12 @@ class LDAPUser(get_user_model()): def update_request(self): request = StoreRequestMiddleware.get_request() if request: - assert not request.session is None + assert request.session is not None self.init_to_session(request.session) def init_from_request(self): request = StoreRequestMiddleware.get_request() - assert request and not request.session is None + assert request and request.session is not None self.init_from_session(request.session) def keep_password(self, password): @@ -377,7 +392,8 @@ class LDAPBackend(object): 'bindpw': '', 'bindsasl': (), 'user_dn_template': '', - 'user_filter': 'uid=%s', # will be '(|(mail=%s)(uid=%s))' if A2_ACCEPT_EMAIL_AUTHENTICATION is set (see update_default) + 'user_filter': 'uid=%s', # will be '(|(mail=%s)(uid=%s))' if + # A2_ACCEPT_EMAIL_AUTHENTICATION is set (see update_default) 'sync_ldap_users_filter': '', 'user_basedn': '', 'group_dn_template': '', @@ -586,7 +602,7 @@ class LDAPBackend(object): if not block['connect_with_user_credentials']: try: self.bind(block, conn) - except Exception as e: + except Exception: log.exception(u'rebind failure after login bind') raise ldap.SERVER_DOWN break @@ -739,8 +755,7 @@ class LDAPBackend(object): for role_name in role_names: role, error = self.get_role(block, role_id=role_name) if role is None: - log.warning('error %s: couldn\'t retrieve role %r', - error, role_name) + log.warning('error %s: couldn\'t retrieve role %r', error, role_name) continue # Add missing roles if dn in role_dns and role not in roles: @@ -842,7 +857,6 @@ class LDAPBackend(object): if group not in groups: user.groups.add(group) - def populate_mandatory_roles(self, user, block): mandatory_roles = block.get('set_mandatory_roles') if not mandatory_roles: @@ -854,8 +868,7 @@ class LDAPBackend(object): for role_name in mandatory_roles: role, error = self.get_role(block, role_id=role_name) if role is None: - log.warning('error %s: couldn\'t retrieve role %r', - error, role_name) + log.warning('error %s: couldn\'t retrieve role %r', error, role_name) continue if role not in roles: user.roles.add(role) @@ -996,7 +1009,6 @@ class LDAPBackend(object): return ' '.join(part for part in parts) def lookup_by_username(self, username): - User = get_user_model() try: log.debug('lookup using username %r', username) return LDAPUser.objects.prefetch_related('groups').get(username=username) @@ -1004,7 +1016,6 @@ class LDAPBackend(object): return def lookup_by_external_id(self, block, attributes): - User = get_user_model() for eid_tuple in map_text(block['external_id_tuples']): external_id = self.build_external_id(eid_tuple, attributes) if not external_id: @@ -1019,7 +1030,7 @@ class LDAPBackend(object): user = users[0] if len(users) > 1: log.info('found %d users, collectings roles into the first one and deleting the other ones.', - len(users)) + len(users)) for other in users[1:]: for r in other.roles.all(): user.roles.add(r) @@ -1312,8 +1323,8 @@ class LDAPBackend(object): if isinstance(cls._DEFAULTS[d], bool) and not isinstance(block[d], bool): raise ImproperlyConfigured( 'LDAP_AUTH_SETTINGS: attribute %r must be a boolean' % d) - if (isinstance(cls._DEFAULTS[d], (list, tuple)) and - not isinstance(block[d], (list, tuple))): + if (isinstance(cls._DEFAULTS[d], (list, tuple)) + and not isinstance(block[d], (list, tuple))): raise ImproperlyConfigured( 'LDAP_AUTH_SETTINGS: attribute %r must be a list or a tuple' % d) if isinstance(cls._DEFAULTS[d], dict) and not isinstance(block[d], dict): diff --git a/src/authentic2/backends/models_backend.py b/src/authentic2/backends/models_backend.py index 2d0d417c..9d961e3f 100644 --- a/src/authentic2/backends/models_backend.py +++ b/src/authentic2/backends/models_backend.py @@ -1,4 +1,4 @@ -# +# authentic2 - versatile identity manager # Copyright (C) 2010-2019 Entr'ouvert # # This program is free software: you can redistribute it and/or modify it diff --git a/src/authentic2/cbv.py b/src/authentic2/cbv.py index 9a7e1f56..d4b9c9d4 100644 --- a/src/authentic2/cbv.py +++ b/src/authentic2/cbv.py @@ -1,3 +1,19 @@ +# 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 . + from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt from django.utils.decorators import method_decorator diff --git a/src/authentic2/compat.py b/src/authentic2/compat.py index 4e36de6d..0f7b71f7 100644 --- a/src/authentic2/compat.py +++ b/src/authentic2/compat.py @@ -1,27 +1,28 @@ -from datetime import datetime +# 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 inspect import django -from django.conf import settings from django.db import connection from django.db.utils import OperationalError from django.core.exceptions import ImproperlyConfigured from django.contrib.auth.tokens import PasswordResetTokenGenerator -try: - from django.contrib.auth import get_user_model -except ImportError: - from django.contrib.auth.models import User - get_user_model = lambda: User - -try: - from django.db.transaction import atomic - commit_on_success = atomic -except ImportError: - from django.db.transaction import commit_on_success - -user_model_label = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') default_token_generator = PasswordResetTokenGenerator() diff --git a/src/authentic2/compat_lasso.py b/src/authentic2/compat_lasso.py index b4cbd26e..3fb19764 100644 --- a/src/authentic2/compat_lasso.py +++ b/src/authentic2/compat_lasso.py @@ -1,3 +1,19 @@ +# 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 . + try: import lasso except ImportError: diff --git a/src/authentic2/constants.py b/src/authentic2/constants.py index 16440858..7d86849d 100644 --- a/src/authentic2/constants.py +++ b/src/authentic2/constants.py @@ -1,3 +1,19 @@ +# 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 . + NONCE_FIELD_NAME = 'nonce' CANCEL_FIELD_NAME = 'cancel' diff --git a/src/authentic2/context_processors.py b/src/authentic2/context_processors.py index f4529ddb..cb78679b 100644 --- a/src/authentic2/context_processors.py +++ b/src/authentic2/context_processors.py @@ -1,17 +1,32 @@ -from collections import defaultdict +# 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 . from pkg_resources import get_distribution from django.conf import settings from . import utils, app_settings, constants + class UserFederations(object): '''Provide access to all federations of the current user''' def __init__(self, request): self.request = request def __getattr__(self, name): - d = { 'provider': None, 'links': [] } + d = {'provider': None, 'links': [] } if name.startswith('service_'): try: provider_id = int(name.split('_', 1)[1]) @@ -29,6 +44,7 @@ class UserFederations(object): __AUTHENTIC2_DISTRIBUTION = None + def a2_processor(request): global __AUTHENTIC2_DISTRIBUTION variables = {} diff --git a/src/authentic2/cors.py b/src/authentic2/cors.py index aae0ffd4..fe38fff0 100644 --- a/src/authentic2/cors.py +++ b/src/authentic2/cors.py @@ -1,3 +1,19 @@ +# 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 . + from .decorators import SessionCache from django.conf import settings diff --git a/src/authentic2/crypto.py b/src/authentic2/crypto.py index 5cb468ca..465f0f6b 100644 --- a/src/authentic2/crypto.py +++ b/src/authentic2/crypto.py @@ -1,3 +1,19 @@ +# 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 base64 import hashlib import struct @@ -101,7 +117,8 @@ def aes_base64url_deterministic_encrypt(key, data, salt, hash_name='sha256', cou iv = hashmod.new(salt).digest() - prf = lambda secret, salt: HMAC.new(secret, salt, hashmod).digest() + def prf(secret, salt): + return HMAC.new(secret, salt, hashmod).digest() aes_key = PBKDF2(key, iv, dkLen=key_size, count=count, prf=prf) @@ -122,7 +139,9 @@ def aes_base64url_deterministic_decrypt(key, urlencoded, salt, raise_on_error=Tr hashmod = SHA256 key_size = 16 hmac_size = key_size - prf = lambda secret, salt: HMAC.new(secret, salt, hashmod).digest() + + def prf(secret, salt): + return HMAC.new(secret, salt, hashmod).digest() try: try: diff --git a/src/authentic2/custom_user/__init__.py b/src/authentic2/custom_user/__init__.py index 5a64bc9a..a45d758e 100644 --- a/src/authentic2/custom_user/__init__.py +++ b/src/authentic2/custom_user/__init__.py @@ -1 +1,17 @@ +# 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 . + default_app_config = 'authentic2.custom_user.apps.CustomUserConfig' diff --git a/src/authentic2/custom_user/apps.py b/src/authentic2/custom_user/apps.py index d220422c..f0f888a2 100644 --- a/src/authentic2/custom_user/apps.py +++ b/src/authentic2/custom_user/apps.py @@ -1,3 +1,19 @@ +# 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 . + from django.db import DEFAULT_DB_ALIAS, router from django.apps import AppConfig diff --git a/src/authentic2/custom_user/management/commands/changepassword.py b/src/authentic2/custom_user/management/commands/changepassword.py index dd538db9..fb7fbfb6 100644 --- a/src/authentic2/custom_user/management/commands/changepassword.py +++ b/src/authentic2/custom_user/management/commands/changepassword.py @@ -1,3 +1,19 @@ +# 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 . + from __future__ import unicode_literals, print_function import getpass @@ -35,7 +51,7 @@ class Command(BaseCommand): UserModel = get_user_model() qs = UserModel._default_manager.using(options.get('database')) - qs = qs.filter(Q(uuid=username)|Q(username=username)|Q(email=username)) + qs = qs.filter(Q(uuid=username) | Q(username=username) | Q(email=username)) try: u = qs.get() except UserModel.DoesNotExist: @@ -44,18 +60,18 @@ class Command(BaseCommand): while True: print('Select a user:') for i, user in enumerate(qs): - print('%d.' % (i+1), user) + print('%d.' % (i + 1), user) print('> ', end=' ') try: j = input() except SyntaxError: print('Please enter an integer') continue - if not isinstance(uid, int): + if not isinstance(j, int): print('Please enter an integer') continue try: - u = qs[j-1] + u = qs[j - 1] break except IndexError: print('Please enter an integer between 1 and %d' % qs.count()) diff --git a/src/authentic2/custom_user/management/commands/fix-attributes.py b/src/authentic2/custom_user/management/commands/fix-attributes.py index f5714a9e..c6916dd4 100644 --- a/src/authentic2/custom_user/management/commands/fix-attributes.py +++ b/src/authentic2/custom_user/management/commands/fix-attributes.py @@ -1,3 +1,19 @@ +# 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 . + from __future__ import unicode_literals, print_function from django.core.management.base import BaseCommand diff --git a/src/authentic2/custom_user/managers.py b/src/authentic2/custom_user/managers.py index dfe260f2..2a3b958a 100644 --- a/src/authentic2/custom_user/managers.py +++ b/src/authentic2/custom_user/managers.py @@ -1,3 +1,19 @@ +# 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 . + from django.db import models from django.utils import timezone from django.contrib.auth.models import BaseUserManager @@ -13,10 +29,12 @@ class UserQuerySet(models.QuerySet): searchable_attributes = Attribute.objects.filter(searchable=True) queries = [] for term in terms: - q = (models.query.Q(username__icontains=term) | - models.query.Q(first_name__icontains=term) | - models.query.Q(last_name__icontains=term) | - models.query.Q(email__icontains=term)) + q = ( + models.query.Q(username__icontains=term) + | models.query.Q(first_name__icontains=term) + | models.query.Q(last_name__icontains=term) + | models.query.Q(email__icontains=term) + ) for a in searchable_attributes: if a.name in ('first_name', 'last_name'): continue diff --git a/src/authentic2/custom_user/models.py b/src/authentic2/custom_user/models.py index 55a8cd77..e7056c91 100644 --- a/src/authentic2/custom_user/models.py +++ b/src/authentic2/custom_user/models.py @@ -1,3 +1,19 @@ +# 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 . + from django.db import models from django.utils import timezone from django.core.mail import send_mail @@ -85,8 +101,8 @@ class IsVerified(object): def __getattr__(self, name): v = getattr(self.user.attributes, name, None) return ( - v is not None and - v == getattr(self.user.verified_attributes, name, None) + v is not None + and v == getattr(self.user.verified_attributes, name, None) ) @@ -103,20 +119,29 @@ class User(AbstractBaseUser, PermissionMixin): Username, password and email are required. Other fields are optional. """ - uuid = models.CharField(_('uuid'), max_length=32, - default=utils.get_hex_uuid, editable=False, unique=True) + uuid = models.CharField( + _('uuid'), + max_length=32, + default=utils.get_hex_uuid, editable=False, unique=True) username = models.CharField(_('username'), max_length=256, null=True, blank=True) first_name = models.CharField(_('first name'), max_length=128, blank=True) last_name = models.CharField(_('last name'), max_length=128, blank=True) - email = models.EmailField(_('email address'), blank=True, - validators=[validators.EmailValidator], max_length=254) + email = models.EmailField( + _('email address'), + blank=True, + validators=[validators.EmailValidator], + max_length=254) email_verified = models.BooleanField( default=False, verbose_name=_('email verified')) - is_staff = models.BooleanField(_('staff status'), default=False, + is_staff = models.BooleanField( + _('staff status'), + default=False, help_text=_('Designates whether the user can log into this admin ' 'site.')) - is_active = models.BooleanField(_('active'), default=True, + is_active = models.BooleanField( + _('active'), + default=True, help_text=_('Designates whether this user should be treated as ' 'active. Unselect this instead of deleting accounts.')) date_joined = models.DateTimeField(_('date joined'), default=timezone.now) diff --git a/src/authentic2/data_transfer.py b/src/authentic2/data_transfer.py index 6f5a5d77..739349da 100644 --- a/src/authentic2/data_transfer.py +++ b/src/authentic2/data_transfer.py @@ -1,3 +1,19 @@ +# 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 . + from django.contrib.contenttypes.models import ContentType from django_rbac.models import Operation diff --git a/src/authentic2/decorators.py b/src/authentic2/decorators.py index 7e544f79..0eb3235b 100644 --- a/src/authentic2/decorators.py +++ b/src/authentic2/decorators.py @@ -1,3 +1,19 @@ +# 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 base64 import pickle import re @@ -6,15 +22,15 @@ from contextlib import contextmanager import time from functools import wraps -from django.contrib.auth.decorators import login_required from django.views.debug import technical_404_response from django.http import Http404, HttpResponseForbidden, HttpResponse, HttpResponseBadRequest from django.core.cache import cache as django_cache from django.core.exceptions import ValidationError from django.utils import six -from . import utils, app_settings, middleware -from .utils import to_list, to_iter +from . import app_settings, middleware +# XXX: import to_list for retrocompaibility +from .utils import to_list, to_iter # noqa: F401 class CacheUnusable(RuntimeError): @@ -32,23 +48,27 @@ def unless(test, message): return f return decorator + def setting_enabled(name, settings=app_settings): '''Generate a decorator for enabling a view based on a setting''' full_name = getattr(settings, 'prefix', '') + name + def test(): return getattr(settings, name, False) return unless(test, 'please enable %s' % full_name) + def lasso_required(): def test(): try: - import lasso + import lasso # noqa: F401 return True except ImportError: return False return unless(test, 'please install lasso') -def required(wrapping_functions,patterns_rslt): + +def required(wrapping_functions, patterns_rslt): ''' Used to require 1..n decorators in any view returned by a url tree @@ -69,36 +89,39 @@ def required(wrapping_functions,patterns_rslt): patterns(...) ) ''' - if not hasattr(wrapping_functions,'__iter__'): + if not hasattr(wrapping_functions, '__iter__'): wrapping_functions = (wrapping_functions,) return [ - _wrap_instance__resolve(wrapping_functions,instance) + _wrap_instance__resolve(wrapping_functions, instance) for instance in patterns_rslt ] -def _wrap_instance__resolve(wrapping_functions,instance): - if not hasattr(instance,'resolve'): return instance - resolve = getattr(instance,'resolve') - def _wrap_func_in_returned_resolver_match(*args,**kwargs): - rslt = resolve(*args,**kwargs) +def _wrap_instance__resolve(wrapping_functions, instance): + if not hasattr(instance, 'resolve'): + return instance + resolve = getattr(instance, 'resolve') - if not hasattr(rslt,'func'):return rslt - f = getattr(rslt,'func') + def _wrap_func_in_returned_resolver_match(*args, **kwargs): + rslt = resolve(*args, **kwargs) + + if not hasattr(rslt, 'func'): + return rslt + f = getattr(rslt, 'func') for _f in reversed(wrapping_functions): # @decorate the function from inner to outter f = _f(f) - setattr(rslt,'func',f) + setattr(rslt, 'func', f) return rslt - setattr(instance,'resolve',_wrap_func_in_returned_resolver_match) - + setattr(instance, 'resolve', _wrap_func_in_returned_resolver_match) return instance + class CacheDecoratorBase(object): '''Base class to build cache decorators. @@ -106,8 +129,8 @@ class CacheDecoratorBase(object): ''' def __new__(cls, *args, **kwargs): if len(args) > 1: - raise TypeError('%s got unexpected arguments, only one argument ' - 'must be given, the function to decorate' % cls.__name__) + raise TypeError( + '%s got unexpected arguments, only one argument must be given, the function to decorate' % cls.__name__) if args: # Case of a decorator used directly return cls(**kwargs)(args[0]) @@ -139,27 +162,27 @@ class CacheDecoratorBase(object): key = self.key(*args, **kwargs) value, tstamp = self.get(key) if tstamp is not None: - if self.timeout is None or \ - tstamp + self.timeout > now: - return value + if (self.timeout is None + or tstamp + self.timeout > now): + return value if hasattr(self, 'delete'): self.delete(key, (key, tstamp)) value = func(*args, **kwargs) self.set(key, (value, now)) return value - except CacheUnusable: # fallback when cache cannot be used + except CacheUnusable: # fallback when cache cannot be used return func(*args, **kwargs) f.cache = self return f def key(self, *args, **kwargs): '''Transform arguments to string and build a key from it''' - parts = [str(id(self))] # add cache instance to the key + parts = [str(id(self))] # add cache instance to the key if self.hostname_vary: request = middleware.StoreRequestMiddleware.get_request() if request: parts.append(request.get_host()) - else: + else: # if we cannot determine the hostname it's better to ignore the # cache raise CacheUnusable @@ -275,6 +298,7 @@ def errorcollector(error_dict): def json(func): '''Convert view to a JSON or JSON web-service supporting CORS''' from . import cors + @wraps(func) def f(request, *args, **kwargs): jsonp = False diff --git a/src/authentic2/disco_service/disco_responder.py b/src/authentic2/disco_service/disco_responder.py index f5115892..30821251 100644 --- a/src/authentic2/disco_service/disco_responder.py +++ b/src/authentic2/disco_service/disco_responder.py @@ -1,3 +1,19 @@ +# 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 . + """ Discovery Service Responder See Identity Provider Discovery Service Protocol and Profile @@ -62,17 +78,14 @@ def get_disco_return_url_from_metadata(entity_id): try: liberty_provider = LibertyProvider.objects.get(entity_id=entity_id) liberty_provider.service_provider - except: - logger.warn("get_disco_return_url_from_metadata: " - "unknown service provider %s" \ - % entity_id) + except Exception: + logger.warn('get_disco_return_url_from_metadata: unknown service provider %s', entity_id) return None dom = parseString(liberty_provider.metadata.encode('utf8')) - endpoints = dom.getElementsByTagNameNS('urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol', 'DiscoveryResponse') + endpoints = dom.getElementsByTagNameNS('urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol', + 'DiscoveryResponse') if not endpoints: - logger.warn("get_disco_return_url_from_metadata: " - "no discovery service endpoint for %s" \ - % entity_id) + logger.warn('get_disco_return_url_from_metadata: no discovery service endpoint for %s', entity_id) return None ep = None value = 0 @@ -89,24 +102,17 @@ def get_disco_return_url_from_metadata(entity_id): value = int(endpoint.attributes['index'].value) ep = endpoint if not ep: - logger.warn("get_disco_return_url_from_metadata: " - "no valid endpoint for %s" \ - % entity_id) + logger.warn("get_disco_return_url_from_metadata: no valid endpoint for %s", entity_id) return None - logger.debug("get_disco_return_url_from_metadata: " - "found endpoint with index %s" \ - % str(value)) + logger.debug('get_disco_return_url_from_metadata: found endpoint with index %s', value) + if 'Location' in ep.attributes.keys(): location = ep.attributes['Location'].value - logger.debug("get_disco_return_url_from_metadata: " - "location is %s" \ - % location) + logger.debug('get_disco_return_url_from_metadata: location is %s', location) return location - logger.warn("get_disco_return_url_from_metadata: " - "no location found for endpoint with index %s" \ - % str(value)) + logger.warn('get_disco_return_url_from_metadata: no location found for endpoint with index %s', value) return None @@ -145,9 +151,7 @@ def disco(request): # Back from the selection interface if idp_selected: - logger.info("disco: " - "back from the idp selection interface with value %s" \ - % idp_selected) + logger.info('disco: back from the idp selection interface with value %s', idp_selected) if not is_known_idp(idp_selected): message = 'The idp is unknown.' @@ -163,21 +167,20 @@ def disco(request): # Discovery request parameters entityID = request.GET.get('entityID', '') _return = request.GET.get('return', '') - policy = request.GET.get('idp_selected', - 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol:single') + policy = request.GET.get('idp_selected', 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol:single') returnIDParam = request.GET.get('returnIDParam', 'entityID') + # XXX: isPassive is unused isPassive = request.GET.get('isPassive', '') if isPassive and isPassive == 'true': - isPassive=True + isPassive = True else: - isPAssive=False + isPassive = False if not entityID: message = _('missing mandatory parameter entityID') return error_page(request, message, logger=logger) - if policy != \ -'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol:single': + if policy != 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol:single': message = _('policy %r not implemented') % policy return error_page(request, message, logger=logger) @@ -189,15 +192,14 @@ def disco(request): else: return_url = _return if not return_url: - message = _('unable to find a valid return url for %s' \ - % entityID) + message = _('unable to find a valid return url for %s') % entityID return error_page(request, message, logger=logger) # Check that the return_url does not already contain a param with name # equal to returnIDParam. Else, it is an unconformant SP. if is_param_id_in_return_url(return_url, returnIDParam): - message = _('invalid return url %(return_url)s for %(entity_id)s' \ - % dict(return_url=return_url, entity_id=entityID)) + message = _('invalid return url %(return_url)s for %(entity_id)s') % dict( + return_url=return_url, entity_id=entityID) return error_page(request, message, logger=logger) # not back from selection interface @@ -208,24 +210,22 @@ def disco(request): if not idp_selected: # no idp selected and we must not interect with the user if isPassive: - #No IdP selected = just return to the return url + # No IdP selected = just return to the return url return HttpResponseRedirect(return_url) # Go to selection interface else: - save_key_values(request, entityID, _return, policy, returnIDParam, - isPassive) + save_key_values(request, entityID, _return, policy, returnIDParam, isPassive) return HttpResponseRedirect(reverse(idp_selection)) # We got it! set_or_refresh_prefered_idp(request, idp_selected) - return HttpResponseRedirect(add_param_to_url(return_url, returnIDParam, - idp_selected)) + return HttpResponseRedirect(add_param_to_url(return_url, returnIDParam, idp_selected)) + def idp_selection(request): # XXX: Code here the IdP selection idp_selected = urlquote('http://www.identity-hub.com/idp/saml2/metadata') - return HttpResponseRedirect('%s?idp_selected=%s' \ - % (reverse(disco), idp_selected)) + return HttpResponseRedirect('%s?idp_selected=%s' % (reverse(disco), idp_selected)) urlpatterns = [ url(r'^disco$', disco), diff --git a/src/authentic2/exponential_retry_timeout.py b/src/authentic2/exponential_retry_timeout.py index 9b71a38e..2cf90e61 100644 --- a/src/authentic2/exponential_retry_timeout.py +++ b/src/authentic2/exponential_retry_timeout.py @@ -1,3 +1,19 @@ +# 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 time import logging import hashlib diff --git a/src/authentic2/forms/__init__.py b/src/authentic2/forms/__init__.py index 8c316f39..e69de29b 100644 --- a/src/authentic2/forms/__init__.py +++ b/src/authentic2/forms/__init__.py @@ -1,273 +0,0 @@ -# -# 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 math - -from django import forms -from django.forms.models import modelform_factory as django_modelform_factory -from django.utils.translation import ugettext_lazy as _ -from django.contrib.auth import REDIRECT_FIELD_NAME, forms as auth_forms -from django.utils import html - -from django.contrib.auth import authenticate - -from django_rbac.utils import get_ou_model - -from authentic2.utils import lazy_label -from authentic2.compat import get_user_model -from authentic2.forms.fields import PasswordField - -from .. import app_settings -from ..exponential_retry_timeout import ExponentialRetryTimeout - -OU = get_ou_model() - - -class EmailChangeFormNoPassword(forms.Form): - email = forms.EmailField(label=_('New email')) - - def __init__(self, user, *args, **kwargs): - self.user = user - super(EmailChangeFormNoPassword, self).__init__(*args, **kwargs) - - -class EmailChangeForm(EmailChangeFormNoPassword): - password = forms.CharField(label=_("Password"), - widget=forms.PasswordInput) - - def clean_email(self): - email = self.cleaned_data['email'] - if email == self.user.email: - raise forms.ValidationError(_('This is already your email address.')) - return email - - def clean_password(self): - password = self.cleaned_data["password"] - if not self.user.check_password(password): - raise forms.ValidationError( - _('Incorrect password.'), - code='password_incorrect', - ) - return password - - -class NextUrlFormMixin(forms.Form): - next_url = forms.CharField(widget=forms.HiddenInput(), required=False) - - def __init__(self, *args, **kwargs): - from authentic2.middleware import StoreRequestMiddleware - - next_url = kwargs.pop('next_url', None) - request = StoreRequestMiddleware.get_request() - if not next_url and request: - next_url = request.GET.get(REDIRECT_FIELD_NAME) - super(NextUrlFormMixin, self).__init__(*args, **kwargs) - if next_url: - self.fields['next_url'].initial = next_url - - -class BaseUserForm(forms.ModelForm): - error_messages = { - 'duplicate_username': _("A user with that username already exists."), - } - - def __init__(self, *args, **kwargs): - from authentic2 import models - - self.attributes = models.Attribute.objects.all() - initial = kwargs.setdefault('initial', {}) - if kwargs.get('instance'): - instance = kwargs['instance'] - for av in models.AttributeValue.objects.with_owner(instance): - if av.attribute.name in self.declared_fields: - if av.verified: - self.declared_fields[av.attribute.name].widget.attrs['readonly'] = 'readonly' - initial[av.attribute.name] = av.to_python() - super(BaseUserForm, self).__init__(*args, **kwargs) - - def clean(self): - from authentic2 import models - - # make sure verified fields are not modified - for av in models.AttributeValue.objects.with_owner( - self.instance).filter(verified=True): - self.cleaned_data[av.attribute.name] = av.to_python() - super(BaseUserForm, self).clean() - - def save_attributes(self): - # only save non verified attributes here - verified_attributes = set( - self.instance.attribute_values.filter(verified=True).values_list('attribute__name', flat=True) - ) - for attribute in self.attributes: - name = attribute.name - if name in self.fields and name not in verified_attributes: - value = self.cleaned_data[name] - setattr(self.instance.attributes, name, value) - - def save(self, commit=True): - result = super(BaseUserForm, self).save(commit=commit) - if commit: - self.save_attributes() - else: - old = self.save_m2m - - def save_m2m(*args, **kwargs): - old(*args, **kwargs) - self.save_attributes() - self.save_m2m = save_m2m - return result - - -class EditProfileForm(NextUrlFormMixin, BaseUserForm): - pass - - -def modelform_factory(model, **kwargs): - '''Build a modelform for the given model, - - For the user model also add attribute based fields. - ''' - from authentic2 import models - - form = kwargs.pop('form', None) - fields = kwargs.get('fields') or [] - required = list(kwargs.pop('required', []) or []) - d = {} - # KV attributes are only supported for the user model currently - modelform = None - if issubclass(model, get_user_model()): - if not form: - form = BaseUserForm - attributes = models.Attribute.objects.all() - for attribute in attributes: - if attribute.name not in fields: - continue - d[attribute.name] = attribute.get_form_field() - for field in app_settings.A2_REQUIRED_FIELDS: - if field not in required: - required.append(field) - if not form or not hasattr(form, 'Meta'): - meta_d = {'model': model, 'fields': '__all__'} - meta = type('Meta', (), meta_d) - d['Meta'] = meta - if not form: # fallback - form = forms.ModelForm - modelform = None - if required: - def __init__(self, *args, **kwargs): - super(modelform, self).__init__(*args, **kwargs) - for field in required: - if field in self.fields: - self.fields[field].required = True - d['__init__'] = __init__ - modelform = type(model.__name__ + 'ModelForm', (form,), d) - kwargs['form'] = modelform - modelform.required_css_class = 'form-field-required' - return django_modelform_factory(model, **kwargs) - - -class AuthenticationForm(auth_forms.AuthenticationForm): - password = PasswordField(label=_('Password')) - remember_me = forms.BooleanField( - initial=False, - required=False, - label=_('Remember me'), - help_text=_('Do not ask for authentication next time')) - ou = forms.ModelChoiceField( - label=lazy_label(_('Organizational unit'), lambda: app_settings.A2_LOGIN_FORM_OU_SELECTOR_LABEL), - required=True, - queryset=OU.objects.all()) - - def __init__(self, *args, **kwargs): - super(AuthenticationForm, self).__init__(*args, **kwargs) - self.exponential_backoff = ExponentialRetryTimeout( - key_prefix='login-exp-backoff-', - duration=app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION, - factor=app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR) - - if not app_settings.A2_USER_REMEMBER_ME: - del self.fields['remember_me'] - - if not app_settings.A2_LOGIN_FORM_OU_SELECTOR: - del self.fields['ou'] - - if self.request: - self.remote_addr = self.request.META['REMOTE_ADDR'] - else: - self.remote_addr = '0.0.0.0' - - def exp_backoff_keys(self): - return self.cleaned_data['username'], self.remote_addr - - def clean(self): - username = self.cleaned_data.get('username') - password = self.cleaned_data.get('password') - - keys = None - if username and password: - keys = self.exp_backoff_keys() - seconds_to_wait = self.exponential_backoff.seconds_to_wait(*keys) - if seconds_to_wait > app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MIN_DURATION: - seconds_to_wait -= app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MIN_DURATION - msg = _('You made too many login errors recently, you must ' - 'wait %s seconds ' - 'to try again.') - msg = msg % int(math.ceil(seconds_to_wait)) - msg = html.mark_safe(msg) - raise forms.ValidationError(msg) - - try: - self.clean_authenticate() - except Exception: - if keys: - self.exponential_backoff.failure(*keys) - raise - else: - if keys: - self.exponential_backoff.success(*keys) - return self.cleaned_data - - def clean_authenticate(self): - # copied from django.contrib.auth.forms.AuthenticationForm to add support for ou selector - username = self.cleaned_data.get('username') - password = self.cleaned_data.get('password') - ou = self.cleaned_data.get('ou') - - if username is not None and password: - self.user_cache = authenticate(username=username, password=password, ou=ou, request=self.request) - if self.user_cache is None: - raise forms.ValidationError( - self.error_messages['invalid_login'], - code='invalid_login', - params={'username': self.username_field.verbose_name}, - ) - else: - self.confirm_login_allowed(self.user_cache) - - return self.cleaned_data - - @property - def media(self): - media = super(AuthenticationForm, self).media - media.add_js(['authentic2/js/js_seconds_until.js']) - if app_settings.A2_LOGIN_FORM_OU_SELECTOR: - media.add_js(['authentic2/js/ou_selector.js']) - return media - - -class SiteImportForm(forms.Form): - site_json = forms.FileField(label=_('Site Export File')) diff --git a/src/authentic2/forms/authentication.py b/src/authentic2/forms/authentication.py new file mode 100644 index 00000000..e49bb73d --- /dev/null +++ b/src/authentic2/forms/authentication.py @@ -0,0 +1,119 @@ +# 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 math + +from django import forms +from django.utils.translation import ugettext_lazy as _ +from django.contrib.auth import forms as auth_forms +from django.utils import html + +from django.contrib.auth import authenticate + +from authentic2.forms.fields import PasswordField + +from ..a2_rbac.models import OrganizationalUnit as OU +from .. import app_settings, utils +from ..exponential_retry_timeout import ExponentialRetryTimeout + + +class AuthenticationForm(auth_forms.AuthenticationForm): + password = PasswordField(label=_('Password')) + remember_me = forms.BooleanField( + initial=False, + required=False, + label=_('Remember me'), + help_text=_('Do not ask for authentication next time')) + ou = forms.ModelChoiceField( + label=utils.lazy_label(_('Organizational unit'), lambda: app_settings.A2_LOGIN_FORM_OU_SELECTOR_LABEL), + required=True, + queryset=OU.objects.all()) + + def __init__(self, *args, **kwargs): + super(AuthenticationForm, self).__init__(*args, **kwargs) + self.exponential_backoff = ExponentialRetryTimeout( + key_prefix='login-exp-backoff-', + duration=app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_DURATION, + factor=app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_FACTOR) + + if not app_settings.A2_USER_REMEMBER_ME: + del self.fields['remember_me'] + + if not app_settings.A2_LOGIN_FORM_OU_SELECTOR: + del self.fields['ou'] + + if self.request: + self.remote_addr = self.request.META['REMOTE_ADDR'] + else: + self.remote_addr = '0.0.0.0' + + def exp_backoff_keys(self): + return self.cleaned_data['username'], self.remote_addr + + def clean(self): + username = self.cleaned_data.get('username') + password = self.cleaned_data.get('password') + + keys = None + if username and password: + keys = self.exp_backoff_keys() + seconds_to_wait = self.exponential_backoff.seconds_to_wait(*keys) + if seconds_to_wait > app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MIN_DURATION: + seconds_to_wait -= app_settings.A2_LOGIN_EXPONENTIAL_RETRY_TIMEOUT_MIN_DURATION + msg = _('You made too many login errors recently, you must ' + 'wait %s seconds ' + 'to try again.') + msg = msg % int(math.ceil(seconds_to_wait)) + msg = html.mark_safe(msg) + raise forms.ValidationError(msg) + + try: + self.clean_authenticate() + except Exception: + if keys: + self.exponential_backoff.failure(*keys) + raise + else: + if keys: + self.exponential_backoff.success(*keys) + return self.cleaned_data + + def clean_authenticate(self): + # copied from django.contrib.auth.forms.AuthenticationForm to add support for ou selector + username = self.cleaned_data.get('username') + password = self.cleaned_data.get('password') + ou = self.cleaned_data.get('ou') + + if username is not None and password: + self.user_cache = authenticate(username=username, password=password, ou=ou, request=self.request) + if self.user_cache is None: + raise forms.ValidationError( + self.error_messages['invalid_login'], + code='invalid_login', + params={'username': self.username_field.verbose_name}, + ) + else: + self.confirm_login_allowed(self.user_cache) + + return self.cleaned_data + + @property + def media(self): + media = super(AuthenticationForm, self).media + media.add_js(['authentic2/js/js_seconds_until.js']) + if app_settings.A2_LOGIN_FORM_OU_SELECTOR: + media.add_js(['authentic2/js/ou_selector.js']) + return media diff --git a/src/authentic2/forms/fields.py b/src/authentic2/forms/fields.py index 613e121f..57ad4507 100644 --- a/src/authentic2/forms/fields.py +++ b/src/authentic2/forms/fields.py @@ -1,3 +1,19 @@ +# 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 warnings import io diff --git a/src/authentic2/forms/passwords.py b/src/authentic2/forms/passwords.py new file mode 100644 index 00000000..12533b42 --- /dev/null +++ b/src/authentic2/forms/passwords.py @@ -0,0 +1,128 @@ +# 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 logging +from collections import OrderedDict + +from django.contrib.auth import forms as auth_forms +from django.core.exceptions import ValidationError +from django.forms import Form +from django import forms +from django.utils.translation import ugettext_lazy as _ + +from .. import models, hooks, app_settings, utils +from ..backends import get_user_queryset +from .fields import PasswordField, NewPasswordField, CheckPasswordField +from .utils import NextUrlFormMixin + + +logger = logging.getLogger(__name__) + + +class PasswordResetForm(forms.Form): + next_url = forms.CharField(widget=forms.HiddenInput, required=False) + + email = forms.EmailField( + label=_("Email"), max_length=254) + + def save(self): + """ + Generates a one-use only link for resetting password and sends to the + user. + """ + email = self.cleaned_data["email"].strip() + users = get_user_queryset() + active_users = users.filter(email__iexact=email, is_active=True) + for user in active_users: + # we don't set the password to a random string, as some users should not have + # a password + set_random_password = (user.has_usable_password() + and app_settings.A2_SET_RANDOM_PASSWORD_ON_RESET) + utils.send_password_reset_mail( + user, + set_random_password=set_random_password, + next_url=self.cleaned_data.get('next_url')) + if not active_users: + logger.info(u'password reset requests for "%s", no user found') + hooks.call_hooks('event', name='password-reset', email=email, users=active_users) + + +class PasswordResetMixin(Form): + '''Remove all password reset object for the current user when password is + successfully changed.''' + + def save(self, commit=True): + ret = super(PasswordResetMixin, self).save(commit=commit) + if commit: + models.PasswordReset.objects.filter(user=self.user).delete() + else: + old_save = self.user.save + + def save(*args, **kwargs): + ret = old_save(*args, **kwargs) + models.PasswordReset.objects.filter(user=self.user).delete() + return ret + self.user.save = save + return ret + + +class NotifyOfPasswordChange(object): + def save(self, commit=True): + user = super(NotifyOfPasswordChange, self).save(commit=commit) + if user.email: + ctx = { + 'user': user, + 'password': self.cleaned_data['new_password1'], + } + utils.send_templated_mail(user, "authentic2/password_change", ctx) + return user + + +class SetPasswordForm(NotifyOfPasswordChange, PasswordResetMixin, auth_forms.SetPasswordForm): + new_password1 = NewPasswordField(label=_("New password")) + new_password2 = CheckPasswordField(label=_("New password confirmation")) + + def clean_new_password1(self): + new_password1 = self.cleaned_data.get('new_password1') + if new_password1 and self.user.check_password(new_password1): + raise ValidationError(_('New password must differ from old password')) + return new_password1 + + +class PasswordChangeForm(NotifyOfPasswordChange, NextUrlFormMixin, PasswordResetMixin, + auth_forms.PasswordChangeForm): + old_password = PasswordField(label=_('Old password')) + new_password1 = NewPasswordField(label=_('New password')) + new_password2 = CheckPasswordField(label=_("New password confirmation")) + + def clean_new_password1(self): + new_password1 = self.cleaned_data.get('new_password1') + old_password = self.cleaned_data.get('old_password') + if new_password1 and new_password1 == old_password: + raise ValidationError(_('New password must differ from old password')) + return new_password1 + +# make old_password the first field +new_base_fields = OrderedDict() + +for k in ['old_password', 'new_password1', 'new_password2']: + new_base_fields[k] = PasswordChangeForm.base_fields[k] + +for k in PasswordChangeForm.base_fields: + if k not in ['old_password', 'new_password1', 'new_password2']: + new_base_fields[k] = PasswordChangeForm.base_fields[k] + +PasswordChangeForm.base_fields = new_base_fields diff --git a/src/authentic2/forms/profile.py b/src/authentic2/forms/profile.py new file mode 100644 index 00000000..5dd81a0d --- /dev/null +++ b/src/authentic2/forms/profile.py @@ -0,0 +1,168 @@ +# 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 . + + +from django.forms.models import modelform_factory as dj_modelform_factory +from django import forms +from django.utils.translation import ugettext_lazy as _, ugettext + +from ..custom_user.models import User +from .. import app_settings, models +from .utils import NextUrlFormMixin + + +class DeleteAccountForm(forms.Form): + password = forms.CharField(widget=forms.PasswordInput, label=_("Password")) + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user') + super(DeleteAccountForm, self).__init__(*args, **kwargs) + + def clean_password(self): + password = self.cleaned_data.get('password') + if password and not self.user.check_password(password): + raise forms.ValidationError(ugettext('Password is invalid')) + return password + + +class EmailChangeFormNoPassword(forms.Form): + email = forms.EmailField(label=_('New email')) + + def __init__(self, user, *args, **kwargs): + self.user = user + super(EmailChangeFormNoPassword, self).__init__(*args, **kwargs) + + +class EmailChangeForm(EmailChangeFormNoPassword): + password = forms.CharField(label=_("Password"), + widget=forms.PasswordInput) + + def clean_email(self): + email = self.cleaned_data['email'] + if email == self.user.email: + raise forms.ValidationError(_('This is already your email address.')) + return email + + def clean_password(self): + password = self.cleaned_data["password"] + if not self.user.check_password(password): + raise forms.ValidationError( + _('Incorrect password.'), + code='password_incorrect', + ) + return password + + +class BaseUserForm(forms.ModelForm): + error_messages = { + 'duplicate_username': _("A user with that username already exists."), + } + + def __init__(self, *args, **kwargs): + from authentic2 import models + + self.attributes = models.Attribute.objects.all() + initial = kwargs.setdefault('initial', {}) + if kwargs.get('instance'): + instance = kwargs['instance'] + for av in models.AttributeValue.objects.with_owner(instance): + if av.attribute.name in self.declared_fields: + if av.verified: + self.declared_fields[av.attribute.name].widget.attrs['readonly'] = 'readonly' + initial[av.attribute.name] = av.to_python() + super(BaseUserForm, self).__init__(*args, **kwargs) + + def clean(self): + from authentic2 import models + + # make sure verified fields are not modified + for av in models.AttributeValue.objects.with_owner( + self.instance).filter(verified=True): + self.cleaned_data[av.attribute.name] = av.to_python() + super(BaseUserForm, self).clean() + + def save_attributes(self): + # only save non verified attributes here + verified_attributes = set( + self.instance.attribute_values.filter(verified=True).values_list('attribute__name', flat=True) + ) + for attribute in self.attributes: + name = attribute.name + if name in self.fields and name not in verified_attributes: + value = self.cleaned_data[name] + setattr(self.instance.attributes, name, value) + + def save(self, commit=True): + result = super(BaseUserForm, self).save(commit=commit) + if commit: + self.save_attributes() + else: + old = self.save_m2m + + def save_m2m(*args, **kwargs): + old(*args, **kwargs) + self.save_attributes() + self.save_m2m = save_m2m + return result + + +class EditProfileForm(NextUrlFormMixin, BaseUserForm): + pass + + +def modelform_factory(model, **kwargs): + '''Build a modelform for the given model, + + For the user model also add attribute based fields. + ''' + + form = kwargs.pop('form', None) + fields = kwargs.get('fields') or [] + required = list(kwargs.pop('required', []) or []) + d = {} + # KV attributes are only supported for the user model currently + modelform = None + if issubclass(model, User): + if not form: + form = BaseUserForm + attributes = models.Attribute.objects.all() + for attribute in attributes: + if attribute.name not in fields: + continue + d[attribute.name] = attribute.get_form_field() + for field in app_settings.A2_REQUIRED_FIELDS: + if field not in required: + required.append(field) + if not form or not hasattr(form, 'Meta'): + meta_d = {'model': model, 'fields': '__all__'} + meta = type('Meta', (), meta_d) + d['Meta'] = meta + if not form: # fallback + form = forms.ModelForm + modelform = None + if required: + def __init__(self, *args, **kwargs): + super(modelform, self).__init__(*args, **kwargs) + for field in required: + if field in self.fields: + self.fields[field].required = True + d['__init__'] = __init__ + modelform = type(model.__name__ + 'ModelForm', (form,), d) + kwargs['form'] = modelform + modelform.required_css_class = 'form-field-required' + return dj_modelform_factory(model, **kwargs) + + diff --git a/src/authentic2/registration_backend/forms.py b/src/authentic2/forms/registration.py similarity index 53% rename from src/authentic2/registration_backend/forms.py rename to src/authentic2/forms/registration.py index 5b99a13c..61d645a9 100644 --- a/src/authentic2/registration_backend/forms.py +++ b/src/authentic2/forms/registration.py @@ -1,28 +1,35 @@ +# 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 re -import copy -from collections import OrderedDict -from django.conf import settings +from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.utils.translation import ugettext_lazy as _, ugettext -from django.forms import ModelForm, Form, CharField, PasswordInput, EmailField -from django.db.models.fields import FieldDoesNotExist -from django.forms.utils import ErrorList +from django.forms import Form, EmailField from django.contrib.auth.models import BaseUserManager, Group -from django.contrib.auth import forms as auth_forms, get_user_model, REDIRECT_FIELD_NAME -from django.core.mail import send_mail -from django.core import signing -from django.template import RequestContext -from django.template.loader import render_to_string -from django.core.urlresolvers import reverse -from django.core.validators import RegexValidator - -from authentic2.forms.fields import PasswordField, NewPasswordField, CheckPasswordField -from .. import app_settings, compat, forms, utils, validators, models, middleware, hooks + +from authentic2.forms.fields import NewPasswordField, CheckPasswordField from authentic2.a2_rbac.models import OrganizationalUnit -User = compat.get_user_model() +from .. import app_settings, models +from . import profile as profile_forms + +User = get_user_model() class RegistrationForm(Form): @@ -53,7 +60,7 @@ class RegistrationForm(Form): return email -class RegistrationCompletionFormNoPassword(forms.BaseUserForm): +class RegistrationCompletionFormNoPassword(profile_forms.BaseUserForm): error_css_class = 'form-field-error' required_css_class = 'form-field-required' @@ -67,7 +74,6 @@ class RegistrationCompletionFormNoPassword(forms.BaseUserForm): ou = OrganizationalUnit.objects.get(pk=self.data['ou']) username_is_unique |= ou.username_is_unique if username_is_unique: - User = get_user_model() exist = False try: User.objects.get(username=username) @@ -86,7 +92,6 @@ class RegistrationCompletionFormNoPassword(forms.BaseUserForm): if self.cleaned_data.get('email'): email = self.cleaned_data['email'] if app_settings.A2_REGISTRATION_EMAIL_IS_UNIQUE: - User = get_user_model() exist = False try: User.objects.get(email__iexact=email) @@ -130,80 +135,3 @@ class RegistrationCompletionForm(RegistrationCompletionFormNoPassword): raise ValidationError(_("The two password fields didn't match.")) self.instance.set_password(self.cleaned_data['password1']) return self.cleaned_data - - -class PasswordResetMixin(Form): - '''Remove all password reset object for the current user when password is - successfully changed.''' - - def save(self, commit=True): - ret = super(PasswordResetMixin, self).save(commit=commit) - if commit: - models.PasswordReset.objects.filter(user=self.user).delete() - else: - old_save = self.user.save - def save(*args, **kwargs): - ret = old_save(*args, **kwargs) - models.PasswordReset.objects.filter(user=self.user).delete() - return ret - self.user.save = save - return ret - - -class NotifyOfPasswordChange(object): - def save(self, commit=True): - user = super(NotifyOfPasswordChange, self).save(commit=commit) - if user.email: - ctx = { - 'user': user, - 'password': self.cleaned_data['new_password1'], - } - utils.send_templated_mail(user, "authentic2/password_change", ctx) - return user - - -class SetPasswordForm(NotifyOfPasswordChange, PasswordResetMixin, auth_forms.SetPasswordForm): - new_password1 = NewPasswordField(label=_("New password")) - new_password2 = CheckPasswordField(label=_("New password confirmation")) - - def clean_new_password1(self): - new_password1 = self.cleaned_data.get('new_password1') - if new_password1 and self.user.check_password(new_password1): - raise ValidationError(_('New password must differ from old password')) - return new_password1 - - -class PasswordChangeForm(NotifyOfPasswordChange, forms.NextUrlFormMixin, PasswordResetMixin, - auth_forms.PasswordChangeForm): - old_password = PasswordField(label=_('Old password')) - new_password1 = NewPasswordField(label=_('New password')) - new_password2 = CheckPasswordField(label=_("New password confirmation")) - - def clean_new_password1(self): - new_password1 = self.cleaned_data.get('new_password1') - old_password = self.cleaned_data.get('old_password') - if new_password1 and new_password1 == old_password: - raise ValidationError(_('New password must differ from old password')) - return new_password1 - -# make old_password the first field -PasswordChangeForm.base_fields = OrderedDict( - [(k, PasswordChangeForm.base_fields[k]) - for k in ['old_password', 'new_password1', 'new_password2']] + - [(k, PasswordChangeForm.base_fields[k]) - for k in PasswordChangeForm.base_fields if k not in ['old_password', 'new_password1', - 'new_password2']] -) - -class DeleteAccountForm(Form): - password = CharField(widget=PasswordInput, label=_("Password")) - - def __init__(self, *args, **kwargs): - self.user = kwargs.pop('user') - super(DeleteAccountForm, self).__init__(*args, **kwargs) - - def clean_password(self): - password = self.cleaned_data.get('password') - if password and not self.user.check_password(password): - raise ValidationError(ugettext('Password is invalid')) - return password diff --git a/src/authentic2/forms/utils.py b/src/authentic2/forms/utils.py new file mode 100644 index 00000000..80c91b9f --- /dev/null +++ b/src/authentic2/forms/utils.py @@ -0,0 +1,33 @@ +# 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 . + +from django import forms +from django.contrib.auth import REDIRECT_FIELD_NAME + +from ..middleware import StoreRequestMiddleware + + +class NextUrlFormMixin(forms.Form): + next_url = forms.CharField(widget=forms.HiddenInput(), required=False) + + def __init__(self, *args, **kwargs): + next_url = kwargs.pop('next_url', None) + request = StoreRequestMiddleware.get_request() + if not next_url and request: + next_url = request.GET.get(REDIRECT_FIELD_NAME) + super(NextUrlFormMixin, self).__init__(*args, **kwargs) + if next_url: + self.fields['next_url'].initial = next_url diff --git a/src/authentic2/forms/widgets.py b/src/authentic2/forms/widgets.py index fdf1e2c0..297bfe20 100644 --- a/src/authentic2/forms/widgets.py +++ b/src/authentic2/forms/widgets.py @@ -1,3 +1,19 @@ +# 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 . + # Bootstrap django-datetime-widget is a simple and clean widget for DateField, # Timefiled and DateTimeField in Django framework. It is based on Bootstrap # datetime picker, supports Bootstrap 2 @@ -12,8 +28,7 @@ import re import uuid import django -from django.forms.widgets import DateTimeInput, DateInput, TimeInput, \ - ClearableFileInput +from django.forms.widgets import DateTimeInput, DateInput, TimeInput, ClearableFileInput from django.forms.widgets import PasswordInput as BasePasswordInput from django.utils.formats import get_language, get_format from django.utils.safestring import mark_safe @@ -95,8 +110,7 @@ class PickerWidgetMixin(object): date_format = self.options['format'] self.format = DATE_FORMAT_TO_PYTHON_REGEX.sub( lambda x: DATE_FORMAT_JS_PY_MAPPING[x.group()], - date_format - ) + date_format) super(PickerWidgetMixin, self).__init__(attrs, format=self.format) @@ -112,7 +126,7 @@ class PickerWidgetMixin(object): final_attrs['class'] = "controls input-append date" rendered_widget = super(PickerWidgetMixin, self).render(name, value, final_attrs) - #if not set, autoclose have to be true. + # if not set, autoclose have to be true. self.options.setdefault('autoclose', True) # Build javascript options out of python dictionary @@ -130,14 +144,12 @@ class PickerWidgetMixin(object): help_text = u'%s %s' % (_('Format:'), self.options['format']) return mark_safe(BOOTSTRAP_INPUT_TEMPLATE % dict( - id=id, - rendered_widget=rendered_widget, - clear_button=CLEAR_BTN_TEMPLATE if self.options.get('clearBtn') else '', - glyphicon=self.glyphicon, - options=js_options, - help_text=help_text, - ) - ) + id=id, + rendered_widget=rendered_widget, + clear_button=CLEAR_BTN_TEMPLATE if self.options.get('clearBtn') else '', + glyphicon=self.glyphicon, + options=js_options, + help_text=help_text)) class DateTimeWidget(PickerWidgetMixin, DateTimeInput): @@ -253,8 +265,8 @@ class CheckPasswordInput(PasswordInput): class ProfileImageInput(ClearableFileInput): if django.VERSION < (1, 9): template_with_initial = ( - '%(initial_text)s: ' - '%(clear_template)s
%(input_text)s: %(input)s' + '%(initial_text)s: ' + '%(clear_template)s
%(input_text)s: %(input)s' ) else: template_name = "authentic2/profile_image_input.html" diff --git a/src/authentic2/hashers.py b/src/authentic2/hashers.py index 3d516b4d..c9c8f0b2 100644 --- a/src/authentic2/hashers.py +++ b/src/authentic2/hashers.py @@ -1,3 +1,19 @@ +# 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 hashlib import math import base64 @@ -66,7 +82,7 @@ class Drupal7PasswordHasher(hashers.BasePasswordHasher): assert salt and '$' not in salt h = salt password = force_bytes(password) - for i in xrange(iterations+1): + for i in range(iterations + 1): h = self.digest(h + password).digest() return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, self.b64encode(h)[:43]) @@ -117,11 +133,26 @@ class CommonPasswordHasher(hashers.BasePasswordHasher): OPENLDAP_ALGO_MAPPING = { - # hasher? salt offset? hex encode? - 'SHA': ( 'sha-oldap', 0, True), - 'SSHA': ('ssha-oldap', 20, True), - 'MD5': ( 'md5-oldap', 0, True), - 'SMD5': ( 'md5-oldap', 16, True), + 'SHA': ( + 'sha-oldap', + 0, + True + ), + 'SSHA': ( + 'ssha-oldap', + 20, + True + ), + 'MD5': ( + 'md5-oldap', + 0, + True + ), + 'SMD5': ( + 'md5-oldap', + 16, + True + ), } diff --git a/src/authentic2/hooks.py b/src/authentic2/hooks.py index 2b7d4445..0212c050 100644 --- a/src/authentic2/hooks.py +++ b/src/authentic2/hooks.py @@ -1,3 +1,19 @@ +# 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 logging from django.apps import apps @@ -37,7 +53,7 @@ def call_hooks(hook_name, *args, **kwargs): for hook in hooks: try: yield hook(*args, **kwargs) - except: + except Exception: logger.exception(u'exception while calling hook %s', hook) @@ -50,5 +66,5 @@ def call_hooks_first_result(hook_name, *args, **kwargs): result = hook(*args, **kwargs) if result is not None: return result - except: + except Exception: logger.exception(u'exception while calling hook %s', hook) diff --git a/src/authentic2/http_utils.py b/src/authentic2/http_utils.py index 1c790723..ee53621f 100644 --- a/src/authentic2/http_utils.py +++ b/src/authentic2/http_utils.py @@ -1,8 +1,25 @@ +# 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 requests from authentic2 import app_settings + def get_url(url): '''Does a simple GET on an URL, check the certificate''' verify = app_settings.A2_VERIFY_SSL diff --git a/src/authentic2/idp/interactions.py b/src/authentic2/idp/interactions.py index 2df88db1..aeb36dfa 100644 --- a/src/authentic2/idp/interactions.py +++ b/src/authentic2/idp/interactions.py @@ -1,83 +1,43 @@ +# 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 . + from django.contrib.auth.decorators import login_required from django.http import HttpResponseRedirect from django.shortcuts import render -from authentic2.saml.models import LibertyProvider - @login_required -def consent_federation(request, nonce = '', next = None, provider_id = None): +def consent_federation(request, nonce='', provider_id=None): '''On a GET produce a form asking for consentment, On a POST handle the form and redirect to next''' if request.method == "GET": - return render(request, 'interaction/consent_federation.html', - {'provider_id': request.GET.get('provider_id', ''), - 'nonce': request.GET.get('nonce', ''), - 'next': request.GET.get('next', '')}) + return render( + request, 'interaction/consent_federation.html', + { + 'provider_id': request.GET.get('provider_id', ''), + 'nonce': request.GET.get('nonce', ''), + 'next': request.GET.get('next', '') + }) else: - next = '/' + next_url = '/' if 'next' in request.POST: - next = request.POST['next'] + next_url = request.POST['next'] if 'accept' in request.POST: - next = next + '&consent_answer=accepted' - return HttpResponseRedirect(next) + next_url = next_url + '&consent_answer=accepted' + return HttpResponseRedirect(next_url) else: - next = next + '&consent_answer=refused' + next_url = next_url + '&consent_answer=refused' return HttpResponseRedirect(next) - -@login_required -def consent_attributes(request, nonce = '', next = None, provider_id = None): - '''On a GET produce a form asking for consentment, - On a POST handle the form and redirect to next''' - provider = None - try: - provider = LibertyProvider.objects.get(entity_id=request.GET.get('provider_id', '')) - except: - pass - next = '/' - - if request.method == "GET": - attributes = [] - next = request.GET.get('next', '') - if 'attributes_to_send' in request.session: - i = 0 - for key, values in request.session['attributes_to_send'].items(): - name = None - if type(key) is tuple and len(key) == 3: - _, _, name = key - elif type(key) is tuple and len(key) == 2: - name, _, = key - else: - name = key - if name and values: - attributes.append((i, name, values)) - i = i + 1 - name = request.GET.get('provider_id', '') - if provider: - name = provider.name or name - return render(request, 'interaction/consent_attributes.html', - {'provider_id': name, - 'attributes': attributes, - 'allow_selection': request.session['allow_attributes_selection'], - 'nonce': request.GET.get('nonce', ''), - 'next': next}) - - elif request.method == "POST": - if request.session['allow_attributes_selection']: - vals = \ - [int(value) for key, value in request.POST.items() \ - if 'attribute_nb' in key] - attributes_to_send = dict() - i = 0 - for k, v in request.session['attributes_to_send'].items(): - if i in vals: - attributes_to_send[k] = v - i = i + 1 - request.session['attributes_to_send'] = attributes_to_send - if 'next' in request.POST: - next = request.POST['next'] - if 'accept' in request.POST: - next = next + '&consent_attribute_answer=accepted' - else: - next = next + '&consent_attribute_answer=refused' - return HttpResponseRedirect(next) diff --git a/src/authentic2/idp/management/commands/cleanup.py b/src/authentic2/idp/management/commands/cleanup.py deleted file mode 100644 index 951f5e5a..00000000 --- a/src/authentic2/idp/management/commands/cleanup.py +++ /dev/null @@ -1,11 +0,0 @@ -import warnings - -from authentic2.idp.management.commands import cleanupauthentic - - -class Command(cleanupauthentic.Command): - def handle_noargs(self, **options): - warnings.warn( - "The `cleanup` command has been deprecated in favor of `cleanupauthentic`.", - PendingDeprecationWarning) - super(Command, self).handle_noargs(**options) diff --git a/src/authentic2/idp/management/commands/cleanupauthentic.py b/src/authentic2/idp/management/commands/cleanupauthentic.py index af892789..458ce142 100644 --- a/src/authentic2/idp/management/commands/cleanupauthentic.py +++ b/src/authentic2/idp/management/commands/cleanupauthentic.py @@ -1,22 +1,39 @@ +# 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 logging from django.apps import apps from django.core.management.base import BaseCommand +logger = logging.getLogger(__name__) + class Command(BaseCommand): help = 'Clean expired models of authentic2.' def handle(self, **options): - log = logging.getLogger(__name__) for app in apps.get_app_configs(): for model in app.get_models(): # only models from authentic2 if model.__module__.startswith('authentic2'): try: self.cleanup_model(model) - except: - log.exception('cleanup of model %s failed', model) + except Exception: + logger.exception('cleanup of model %s failed', model) def cleanup_model(self, model): manager = getattr(model, 'objects', None) diff --git a/src/authentic2/idp/middleware.py b/src/authentic2/idp/middleware.py deleted file mode 100644 index 478a7e5d..00000000 --- a/src/authentic2/idp/middleware.py +++ /dev/null @@ -1,8 +0,0 @@ -import traceback - -from django.conf import settings - -class DebugMiddleware: - def process_exception(self, request, exception): - if getattr(settings, 'DEBUG', False): - traceback.print_exc() diff --git a/src/authentic2/idp/models.py b/src/authentic2/idp/models.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2/idp/saml/__init__.py b/src/authentic2/idp/saml/__init__.py index 81c90c4e..65058aa3 100644 --- a/src/authentic2/idp/saml/__init__.py +++ b/src/authentic2/idp/saml/__init__.py @@ -1,5 +1,20 @@ +# 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 . + from django.conf import settings -from django.utils.translation import ugettext_lazy as _ from django.core.checks import register, Warning, Tags from django.apps import AppConfig @@ -44,9 +59,10 @@ def check_authentic2_config(app_configs, **kwargs): from . import app_settings errors = [] - if not settings.DEBUG and app_settings.ENABLE and \ - (app_settings.is_default('SIGNATURE_PUBLIC_KEY') or - app_settings.is_default('SIGNATURE_PRIVATE_KEY')): + if (not settings.DEBUG + and app_settings.ENABLE + and (app_settings.is_default('SIGNATURE_PUBLIC_KEY') + or app_settings.is_default('SIGNATURE_PRIVATE_KEY'))): errors.append( Warning( 'You should not use default SAML keys in production', @@ -57,5 +73,4 @@ def check_authentic2_config(app_configs, **kwargs): ) return errors -check_authentic2_config = register(Tags.security, - deploy=True)(check_authentic2_config) +check_authentic2_config = register(Tags.security, deploy=True)(check_authentic2_config) diff --git a/src/authentic2/idp/saml/app_settings.py b/src/authentic2/idp/saml/app_settings.py index f32c3947..23ffc182 100644 --- a/src/authentic2/idp/saml/app_settings.py +++ b/src/authentic2/idp/saml/app_settings.py @@ -1,10 +1,29 @@ +# 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 sys + + class AppSettings(object): __DEFAULTS = dict( - ENABLE=False, - METADATA_OPTIONS={}, - SECONDS_TOLERANCE=60, - AUTHN_CONTEXT_FROM_SESSION=True, - SIGNATURE_PUBLIC_KEY = '''-----BEGIN CERTIFICATE----- + ENABLE=False, + METADATA_OPTIONS={}, + SECONDS_TOLERANCE=60, + AUTHN_CONTEXT_FROM_SESSION=True, + SIGNATURE_PUBLIC_KEY='''-----BEGIN CERTIFICATE----- MIIDIzCCAgugAwIBAgIJANUBoick1pDpMA0GCSqGSIb3DQEBBQUAMBUxEzARBgNV BAoTCkVudHJvdXZlcnQwHhcNMTAxMjE0MTUzMzAyWhcNMTEwMTEzMTUzMzAyWjAV MRMwEQYDVQQKEwpFbnRyb3V2ZXJ0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB @@ -23,7 +42,7 @@ lG6l41SXp6YgIb2ToT+rOKdIGIQuGDlzeR88fDxWEU0vEujZv/v1PE1YOV0xKjTT JumlBc6IViKhJeo1wiBBrVRIIkKKevHKQzteK8pWm9CYWculxT26TZ4VWzGbo06j o2zbumirrLLqnt1gmBDvDvlOwC/zAAyL4chbz66eQHTiIYZZvYgy -----END CERTIFICATE-----''', - SIGNATURE_PRIVATE_KEY = '''-----BEGIN RSA PRIVATE KEY----- + SIGNATURE_PRIVATE_KEY='''-----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEAvxFkfPdndlGgQPDZgFGXbrNAc/79PULZBuNdWFHDD9P5hNhZ n9Kqm4Cp06Pe/A6u+g5wLnYvbZQcFCgfQAEzziJtb3J55OOlB7iMEI/T2AX2WzrU H8QT8NGhABONKU2Gg4XiyeXNhH5R7zdHlUwcWq3ZwNbtbY0TVc+n665EbrfV/59x @@ -50,8 +69,8 @@ gmsgaiMCgYB/nrTk89Fp7050VKCNnIt1mHAcO9cBwDV8qrJ5O3rIVmrg1T6vn0aY wRiVcNacaP+BivkrMjr4BlsUM6yH4MOBsNhLURiiCL+tLJV7U0DWlCse/doWij4U TKX6tp6oI+7MIJE6ySZ0cBqOiydAkBePZhu57j6ToBkTa0dbHjn1WA== -----END RSA PRIVATE KEY-----''', - ADD_CERTIFICATE_TO_KEY_INFO=True, - SIGNATURE_METHOD='RSA-SHA256', + ADD_CERTIFICATE_TO_KEY_INFO=True, + SIGNATURE_METHOD='RSA-SHA256', ) def __init__(self, prefix): @@ -67,26 +86,26 @@ TKX6tp6oI+7MIJE6ySZ0cBqOiydAkBePZhu57j6ToBkTa0dbHjn1WA== @property def ENABLE(self): return self._setting_with_prefix('ENABLE', - self._setting('IDP_SAML2', - self.__DEFAULTS['ENABLE'])) + self._setting('IDP_SAML2', + self.__DEFAULTS['ENABLE'])) @property def SIGNATURE_PUBLIC_KEY(self): return self._setting_with_prefix('SIGNATURE_PUBLIC_KEY', - self._setting('SAML_SIGNATURE_PUBLIC_KEY', - self.__DEFAULTS['SIGNATURE_PUBLIC_KEY'])) + self._setting('SAML_SIGNATURE_PUBLIC_KEY', + self.__DEFAULTS['SIGNATURE_PUBLIC_KEY'])) @property def SIGNATURE_PRIVATE_KEY(self): return self._setting_with_prefix('SIGNATURE_PRIVATE_KEY', - self._setting('SAML_SIGNATURE_PRIVATE_KEY', - self.__DEFAULTS['SIGNATURE_PRIVATE_KEY'])) + self._setting('SAML_SIGNATURE_PRIVATE_KEY', + self.__DEFAULTS['SIGNATURE_PRIVATE_KEY'])) @property def AUTHN_CONTEXT_FROM_SESSION(self): return self._setting_with_prefix('AUTHN_CONTEXT_FROM_SESSION', - self._setting('IDP_SAML2_AUTHN_CONTEXT_FROM_SESSION', - self.__DEFAULTS['AUTHN_CONTEXT_FROM_SESSION'])) + self._setting('IDP_SAML2_AUTHN_CONTEXT_FROM_SESSION', + self.__DEFAULTS['AUTHN_CONTEXT_FROM_SESSION'])) def is_default(self, name): return getattr(self, name) == self.__DEFAULTS[name] @@ -96,10 +115,8 @@ TKX6tp6oI+7MIJE6ySZ0cBqOiydAkBePZhu57j6ToBkTa0dbHjn1WA== raise AttributeError(name) return self._setting_with_prefix(name, self.__DEFAULTS[name]) - # Ugly? Guido recommends this himself ... # http://mail.python.org/pipermail/python-ideas/2012-May/014969.html -import sys app_settings = AppSettings('A2_IDP_SAML2_') app_settings.__name__ = __name__ sys.modules[__name__] = app_settings diff --git a/src/authentic2/idp/saml/backend.py b/src/authentic2/idp/saml/backend.py index 8535264f..59759ed3 100644 --- a/src/authentic2/idp/saml/backend.py +++ b/src/authentic2/idp/saml/backend.py @@ -1,3 +1,19 @@ +# 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 logging import operator import random diff --git a/src/authentic2/idp/saml/common.py b/src/authentic2/idp/saml/common.py index 7667e167..06c55bd3 100644 --- a/src/authentic2/idp/saml/common.py +++ b/src/authentic2/idp/saml/common.py @@ -1,21 +1,38 @@ +# 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 logging -from django.contrib.auth import REDIRECT_FIELD_NAME, SESSION_KEY +from django.contrib.auth import REDIRECT_FIELD_NAME from django.utils.http import urlencode from importlib import import_module from django.conf import settings from django.http import HttpResponseRedirect -def redirect_to_login(next, login_url=None, - redirect_field_name=REDIRECT_FIELD_NAME, other_keys = {}): + +def redirect_to_login(next_url, login_url=None, redirect_field_name=REDIRECT_FIELD_NAME, other_keys={}): "Redirects the user to the login page, passing the given 'next' page" if not login_url: login_url = settings.LOGIN_URL - data = { redirect_field_name: next } + data = {redirect_field_name: next_url} for k, v in other_keys.items(): data[k] = v return HttpResponseRedirect('%s?%s' % (login_url, urlencode(data))) + def kill_django_sessions(session_key): engine = import_module(settings.SESSION_ENGINE) try: diff --git a/src/authentic2/idp/saml/saml2_endpoints.py b/src/authentic2/idp/saml/saml2_endpoints.py index 6e6978fa..5bc0405d 100644 --- a/src/authentic2/idp/saml/saml2_endpoints.py +++ b/src/authentic2/idp/saml/saml2_endpoints.py @@ -1,3 +1,19 @@ +# 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 . + """SAML2.0 IdP implementation It contains endpoints to receive: @@ -26,6 +42,7 @@ from functools import wraps from authentic2.compat_lasso import lasso from django.core.urlresolvers import reverse +from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.core.exceptions import ObjectDoesNotExist from django.http import HttpResponse, HttpResponseRedirect, \ @@ -44,13 +61,12 @@ from django.shortcuts import render, redirect from django.contrib import messages -from authentic2.compat import get_user_model import authentic2.views as a2_views -from authentic2.saml.models import (LibertyArtifact, - LibertySession, LibertyFederation, - nameid2kwargs, saml2_urn_to_nidformat, - nidformat_to_saml2_urn, save_key_values, get_and_delete_key_values, - LibertyProvider, LibertyServiceProvider, SAMLAttribute, NAME_ID_FORMATS) +from authentic2.saml.models import ( + LibertyArtifact, LibertySession, LibertyFederation, nameid2kwargs, + saml2_urn_to_nidformat, nidformat_to_saml2_urn, save_key_values, + get_and_delete_key_values, LibertyProvider, LibertyServiceProvider, + SAMLAttribute, NAME_ID_FORMATS) from authentic2.saml.common import redirect_next, asynchronous_bindings, \ soap_bindings, load_provider, get_saml2_request_message, \ error_page, set_saml2_response_responder_status_code, \ @@ -74,8 +90,9 @@ from authentic2.constants import NONCE_FIELD_NAME from authentic2.idp import signals as idp_signals -from authentic2.utils import (make_url, get_backends as get_idp_backends, - get_username, login_require, find_authentication_event, datetime_to_xs_datetime) +from authentic2.utils import ( + make_url, get_backends as get_idp_backends, get_username, login_require, + find_authentication_event, datetime_to_xs_datetime) from authentic2 import utils from authentic2.attributes_ng.engine import get_attributes from authentic2 import hooks @@ -83,28 +100,27 @@ from authentic2 import hooks from . import app_settings +User = get_user_model() + logger = logging.getLogger(__name__) def get_nonce(): - alphabet = string.ascii_letters+string.digits - return '_'+''.join(random.SystemRandom().choice(alphabet) for i in xrange(20)) + alphabet = string.ascii_letters + string.digits + return '_' + ''.join(random.SystemRandom().choice(alphabet) for i in range(20)) metadata_map = ( - (saml2utils.Saml2Metadata.SINGLE_SIGN_ON_SERVICE, - asynchronous_bindings, '/sso'), - (saml2utils.Saml2Metadata.SINGLE_LOGOUT_SERVICE, - asynchronous_bindings, '/slo', '/slo_return'), - (saml2utils.Saml2Metadata.SINGLE_LOGOUT_SERVICE, - soap_bindings, '/slo/soap'), - (saml2utils.Saml2Metadata.ARTIFACT_RESOLUTION_SERVICE, - lasso.SAML2_METADATA_BINDING_SOAP, '/artifact') + (saml2utils.Saml2Metadata.SINGLE_SIGN_ON_SERVICE, asynchronous_bindings, '/sso'), + (saml2utils.Saml2Metadata.SINGLE_LOGOUT_SERVICE, asynchronous_bindings, '/slo', '/slo_return'), + (saml2utils.Saml2Metadata.SINGLE_LOGOUT_SERVICE, soap_bindings, '/slo/soap'), + (saml2utils.Saml2Metadata.ARTIFACT_RESOLUTION_SERVICE, lasso.SAML2_METADATA_BINDING_SOAP, '/artifact') ) + def metadata(request): '''Endpoint to retrieve the metadata file''' - return HttpResponse(get_metadata(request, request.path), - content_type='text/xml') + return HttpResponse(get_metadata(request, request.path), content_type='text/xml') + def log_assert(func, exception_classes=(AssertionError,)): '''Convert assertion errors to warning logs and report them to the user @@ -123,11 +139,14 @@ def log_assert(func, exception_classes=(AssertionError,)): ##### # SSO ##### + + def register_new_saml2_session(request, login): '''Persist the newly created session for emitted assertion''' - lib_session = LibertySession(provider_id=login.remoteProviderId, - saml2_assertion=login.assertion, - django_session_key=request.session.session_key) + lib_session = LibertySession( + provider_id=login.remoteProviderId, + saml2_assertion=login.assertion, + django_session_key=request.session.session_key) lib_session.save() @@ -158,8 +177,7 @@ def fill_assertion(request, saml_request, assertion, provider_id, nid_format): # Generate the transient identifier from the session key, to fix it for # a session duration, without that logout is broken as you can send # many session_index in a logout request but only one NameID - keys = ''.join([request.session.session_key, provider_id, - settings.SECRET_KEY]) + keys = ''.join([request.session.session_key, provider_id, settings.SECRET_KEY]) transient_id_content = '_' + hashlib.sha1(keys).hexdigest().upper() assertion.subject.nameID.content = transient_id_content if nid_format == 'email': @@ -172,29 +190,28 @@ def fill_assertion(request, saml_request, assertion, provider_id, nid_format): assertion.subject.nameID.content = request.user.uuid if nid_format == 'edupersontargetedid': assertion.subject.nameID.format = NAME_ID_FORMATS[nid_format]['samlv2'] - keys = ''.join([get_username(request.user), - provider_id, settings.SECRET_KEY]) + keys = ''.join([get_username(request.user), provider_id, settings.SECRET_KEY]) edu_person_targeted_id = '_' + hashlib.sha1(keys).hexdigest().upper() assertion.subject.nameID.content = edu_person_targeted_id attribute_definition = ('urn:oid:1.3.6.1.4.1.5923.1.1.1.10', - lasso.SAML2_ATTRIBUTE_NAME_FORMAT_URI, 'eduPersonTargetedID') + lasso.SAML2_ATTRIBUTE_NAME_FORMAT_URI, + 'eduPersonTargetedID') value = assertion.subject.nameID.exportToXml() value = ctree.fromstring(value) - saml2_add_attribute_values(assertion, - { attribute_definition: [ value ]}) + saml2_add_attribute_values(assertion, {attribute_definition: [value]}) logger.debug('adding an eduPersonTargetedID attribute with value %s', edu_person_targeted_id) assertion.subject.nameID.format = NAME_ID_FORMATS[nid_format]['samlv2'] + def get_attribute_definitions(provider): '''Query all attribute definitions for a providers''' - qs = SAMLAttribute.objects.for_generic_object(provider) \ - .filter(enabled=True) + qs = SAMLAttribute.objects.for_generic_object(provider).filter(enabled=True) sp_options_policy = get_sp_options_policy(provider) if sp_options_policy: - qs |= SAMLAttribute.objects.for_generic_object(sp_options_policy) \ - .filter(enabled=True) + qs |= SAMLAttribute.objects.for_generic_object(sp_options_policy).filter(enabled=True) return qs.distinct() + def add_attributes(request, assertion, provider): qs = get_attribute_definitions(provider) wanted_attributes = [definition.attribute_name for definition in qs] @@ -349,17 +366,17 @@ def build_assertion(request, login, nid_format='transient'): elif how.startswith('oath-totp'): authn_context = lasso.SAML2_AUTHN_CONTEXT_TIME_SYNC_TOKEN else: - raise NotImplementedError('Unknown authentication method %s', - how) + raise NotImplementedError('Unknown authentication method %s', how) except ObjectDoesNotExist: # TODO: previous session over secure transport (ssl) ? authn_context = lasso.SAML2_AUTHN_CONTEXT_PREVIOUS_SESSION logger.debug('authn_context is %s', authn_context) - login.buildAssertion(authn_context, - now.isoformat() + 'Z', - 'unused', # reauthenticateOnOrAfter is only for ID-FF 1.2 - notBefore.isoformat() + 'Z', - notOnOrAfter.isoformat() + 'Z') + login.buildAssertion( + authn_context, + now.isoformat() + 'Z', + 'unused', # reauthenticateOnOrAfter is only for ID-FF 1.2 + notBefore.isoformat() + 'Z', + notOnOrAfter.isoformat() + 'Z') assertion = login.assertion assertion.conditions.notOnOrAfter = notOnOrAfter.isoformat() + 'Z' # Set SessionNotOnOrAfter to expiry date of the current session, so we are sure no session on @@ -367,8 +384,7 @@ def build_assertion(request, login, nid_format='transient'): expiry_date = request.session.get_expiry_date() assertion.authnStatement[0].sessionNotOnOrAfter = datetime_to_xs_datetime(expiry_date) logger.debug('assertion building in progress %s', assertion.dump()) - fill_assertion(request, login.request, assertion, login.remoteProviderId, - nid_format) + fill_assertion(request, login.request, assertion, login.remoteProviderId, nid_format) # Save federation and new session if nid_format == 'persistent': logger.debug('nameID persistent, get or create federation') @@ -379,11 +395,12 @@ def build_assertion(request, login, nid_format='transient'): kwargs['name_id_qualifier'] = AUTHENTIC_SAME_ID_SENTINEL if kwargs.get('name_id_sp_name_qualifier') == login.remoteProviderId: kwargs['name_id_sp_name_qualifier'] = AUTHENTIC_SAME_ID_SENTINEL - service_provider = LibertyServiceProvider.objects \ - .get(liberty_provider__entity_id=login.remoteProviderId) + service_provider = LibertyServiceProvider.objects.get( + liberty_provider__entity_id=login.remoteProviderId) federation, new = LibertyFederation.objects.get_or_create( - sp=service_provider, - user=request.user, **kwargs) + sp=service_provider, + user=request.user, + **kwargs) if new: logger.debug('nameID persistent, new federation') federation.save() @@ -396,8 +413,7 @@ def build_assertion(request, login, nid_format='transient'): kwargs['entity_id'] = login.remoteProviderId kwargs['user'] = request.user logger.debug(u'sending nameID %(name_id_format)r: %(name_id_content)r to ' - u'%(entity_id)s for user %(user)s' % kwargs) - + u'%(entity_id)s for user %(user)s' % kwargs) register_new_saml2_session(request, login) return kwargs['name_id_content'] @@ -436,15 +452,16 @@ def sso(request): logger.warning( 'invalid message for WebSSO profile with HTTP-Redirect binding: ' '%r exception: %s', message, e, extra={'request': request}) - return HttpResponseBadRequest(_("SAMLv2 Single Sign On: " - "invalid message for WebSSO profile with HTTP-Redirect " - "binding: %r") % message, content_type='text/plain') + return HttpResponseBadRequest( + _("SAMLv2 Single Sign On: invalid message for WebSSO profile with HTTP-Redirect " + "binding: %r") % message, content_type='text/plain') except lasso.ProfileInvalidProtocolprofileError: log_info_authn_request_details(login) - message = _("SAMLv2 Single Sign On: the request cannot be " + message = _( + "SAMLv2 Single Sign On: the request cannot be " "answered because no valid protocol binding could be found") - logger.warning('the request cannot be answered because no ' - 'valid protocol binding could be found') + logger.warning( + 'the request cannot be answered because no valid protocol binding could be found') return HttpResponseBadRequest(message, content_type='text/plain') except lasso.ProviderMissingPublicKeyError as e: log_info_authn_request_details(login) @@ -462,20 +479,23 @@ def sso(request): log_info_authn_request_details(login) provider_id = login.remoteProviderId logger.debug('loading provider %s' % provider_id) - provider_loaded = load_provider(request, provider_id, - server=login.server, autoload=True) + provider_loaded = load_provider(request, provider_id, server=login.server, autoload=True) if not provider_loaded: add_url = reverse('admin:saml_libertyprovider_add_from_url') - add_url += '?' + urlencode({ 'entity_id': provider_id }) - return render(request, - 'idp/saml/unknown_provider.html', - { 'entity_id': provider_id, - 'add_url': add_url, - }) + add_url += '?' + urlencode({'entity_id': provider_id}) + return render( + request, + 'idp/saml/unknown_provider.html', + { + 'entity_id': provider_id, + 'add_url': add_url, + }) else: policy = get_sp_options_policy(provider_loaded) if not policy: - return error_page(request, _('sso: No SP policy defined'), + return error_page( + request, + _('sso: No SP policy defined'), logger=logger, warning=True) logger.debug('provider %s loaded with success', provider_id) if policy.authn_request_signed: @@ -487,17 +507,16 @@ def sso(request): if signed and not check_destination(request, login.request): logger.warning('wrong or absent destination') - return return_login_error(request, login, - AUTHENTIC_STATUS_CODE_MISSING_DESTINATION) + return return_login_error( + request, login, + AUTHENTIC_STATUS_CODE_MISSING_DESTINATION) # Check NameIDPolicy or force the NameIDPolicy name_id_policy = login.request.nameIdPolicy - if name_id_policy and \ - name_id_policy.format and \ - name_id_policy.format != \ - lasso.SAML2_NAME_IDENTIFIER_FORMAT_UNSPECIFIED: + if (name_id_policy + and name_id_policy.format + and name_id_policy.format != lasso.SAML2_NAME_IDENTIFIER_FORMAT_UNSPECIFIED): logger.debug('nameID policy is %s', name_id_policy.dump()) - nid_format = saml2_urn_to_nidformat(name_id_policy.format, - accepted=policy.accepted_name_id_format) + nid_format = saml2_urn_to_nidformat(name_id_policy.format, accepted=policy.accepted_name_id_format) logger.debug('nameID format %s', nid_format) default_nid_format = policy.default_name_id_format logger.debug('default nameID format %s', default_nid_format) @@ -505,8 +524,7 @@ def sso(request): logger.debug('nameID format accepted %s', accepted_nid_format) if (not nid_format or nid_format not in accepted_nid_format) and \ default_nid_format != nid_format: - set_saml2_response_responder_status_code(login.response, - lasso.SAML2_STATUS_CODE_INVALID_NAME_ID_POLICY) + set_saml2_response_responder_status_code(login.response, lasso.SAML2_STATUS_CODE_INVALID_NAME_ID_POLICY) logger.warning('NameID format required is not accepted') return finish_sso(request, login) else: @@ -576,41 +594,36 @@ def continue_sso(request): logger.warning('nonce not found') return HttpResponseBadRequest() try: - login_dump, consent_obtained, nid_format = \ - get_and_delete_key_values(nonce) + login_dump, consent_obtained, nid_format = get_and_delete_key_values(nonce) except KeyError: messages.warning(request, N_('request has expired')) return utils.redirect(request, 'auth_homepage') server = create_server(request) # Work Around for lasso < 2.3.6 - login_dump = login_dump.replace('', '') + login_dump = login_dump.replace('', '') login = lasso.Login.newFromDump(server, login_dump) logger.debug('login newFromDump done') if not login: - return error_page(request, _('continue_sso: error loading login'), - logger=logger) - if not load_provider(request, login.remoteProviderId, server=login.server, - autoload=True): - return error_page(request, _('continue_sso: unknown provider %s') \ - % login.remoteProviderId, logger=logger) + return error_page(request, _('continue_sso: error loading login'), logger=logger) + if not load_provider(request, login.remoteProviderId, server=login.server, autoload=True): + return error_page(request, _('continue_sso: unknown provider %s') % login.remoteProviderId, logger=logger) if 'cancel' in request.GET: logger.debug('login canceled') - set_saml2_response_responder_status_code(login.response, - lasso.SAML2_STATUS_CODE_REQUEST_DENIED) + set_saml2_response_responder_status_code(login.response, lasso.SAML2_STATUS_CODE_REQUEST_DENIED) return finish_sso(request, login) if consent_answer == 'refused': logger.debug('consent answer treatment, the user refused, return request denied to the requester') - set_saml2_response_responder_status_code(login.response, - lasso.SAML2_STATUS_CODE_REQUEST_DENIED) + set_saml2_response_responder_status_code(login.response, lasso.SAML2_STATUS_CODE_REQUEST_DENIED) return finish_sso(request, login) if consent_answer == 'accepted': logger.debug('consent answer treatment, the user accepted, continue') consent_obtained = True - return sso_after_process_request(request, login, - consent_obtained=consent_obtained, - consent_attribute_answer=consent_attribute_answer, - nid_format=nid_format) + return sso_after_process_request( + request, + login, + consent_obtained=consent_obtained, + consent_attribute_answer=consent_attribute_answer, + nid_format=nid_format) def needs_persistence(nid_format): @@ -618,8 +631,8 @@ def needs_persistence(nid_format): def sso_after_process_request(request, login, consent_obtained=False, - consent_attribute_answer=False, user=None, - nid_format='transient', return_profile=False): + consent_attribute_answer=False, user=None, + nid_format='transient', return_profile=False): """Common path for sso and idp_initiated_sso. consent_obtained: whether the user has given his consent to this @@ -648,30 +661,30 @@ def sso_after_process_request(request, login, consent_obtained=False, # No user is authenticated and passive is True, deny request if passive and user.is_anonymous(): logger.debug('no user connected and passive request, returning NoPassive') - set_saml2_response_responder_status_code(login.response, - lasso.SAML2_STATUS_CODE_NO_PASSIVE) + set_saml2_response_responder_status_code(login.response, lasso.SAML2_STATUS_CODE_NO_PASSIVE) return finish_sso(request, login) service.authorize(request.user) hooks.call_hooks('event', name='sso-request', idp='saml2', service=service) - #Do not ask consent for federation if a transient nameID is provided + # Do not ask consent for federation if a transient nameID is provided transient = False if nid_format == 'transient': transient = True - decisions = idp_signals.authorize_service.send(sender=None, - request=request, user=request.user, audience=login.remoteProviderId, - attributes={}) + decisions = idp_signals.authorize_service.send( + sender=None, + request=request, user=request.user, + audience=login.remoteProviderId, + attributes={}) logger.debug('signal authorize_service sent') # You don't dream. By default, access granted. # We catch denied decisions i.e. dic['authz'] = False access_granted = True for decision in decisions: - logger.debug('authorize_service connected ' - 'to function %s' % decision[0].__name__) + logger.debug('authorize_service connected to function %s' % decision[0].__name__) dic = decision[1] if dic and 'authz' in dic: logger.debug('decision is %s', dic['authz']) @@ -685,21 +698,21 @@ def sso_after_process_request(request, login, consent_obtained=False, if not access_granted: logger.debug('access denied, return answer to the requester') - set_saml2_response_responder_status_code(login.response, - lasso.SAML2_STATUS_CODE_REQUEST_DENIED, - msg=six.text_type(dic['message'])) + set_saml2_response_responder_status_code( + login.response, + lasso.SAML2_STATUS_CODE_REQUEST_DENIED, + msg=six.text_type(dic['message'])) return finish_sso(request, login) - provider = load_provider(request, login.remoteProviderId, - server=login.server) + provider = load_provider(request, login.remoteProviderId, server=login.server) if not provider: - return error_page(request, + return error_page( + request, _('Provider %s is unknown') % login.remoteProviderId, logger=logger) saml_policy = get_sp_options_policy(provider) if not saml_policy: - return error_page(request, _('No service provider policy defined'), - logger=logger) + return error_page(request, _('No service provider policy defined'), logger=logger) '''User consent for federation management @@ -750,18 +763,17 @@ def sso_after_process_request(request, login, consent_obtained=False, consent_value = 'urn:oasis:names:tc:SAML:2.0:consent:unavailable' if not consent_obtained and not transient: - consent_obtained = \ - not saml_policy.ask_user_consent + consent_obtained = not saml_policy.ask_user_consent logger.debug('the policy says %s', consent_obtained) if consent_obtained: - #The user consent is bypassed by the policy + # The user consent is bypassed by the policy consent_value = 'urn:oasis:names:tc:SAML:2.0:consent:unspecified' if needs_persistence(nid_format): try: LibertyFederation.objects.get( - user=request.user, - sp__liberty_provider__entity_id=login.remoteProviderId) + user=request.user, + sp__liberty_provider__entity_id=login.remoteProviderId) logger.debug('consent already given (existing federation) for %s', login.remoteProviderId) consent_obtained = True '''This is abusive since a federation may exist even if we have @@ -773,14 +785,15 @@ def sso_after_process_request(request, login, consent_obtained=False, if not consent_obtained and not transient: logger.debug('signal avoid_consent sent') avoid_consent = idp_signals.avoid_consent.send(sender=None, - request=request, user=request.user, - audience=login.remoteProviderId) + request=request, + user=request.user, + audience=login.remoteProviderId) for c in avoid_consent: logger.debug('avoid_consent connected to function %s', c[0].__name__) if c[1] and 'avoid_consent' in c[1] and c[1]['avoid_consent']: logger.debug('avoid consent by signal') consent_obtained = True - #The user consent is bypassed by the signal + # The user consent is bypassed by the signal consent_value = \ 'urn:oasis:names:tc:SAML:2.0:consent:unspecified' @@ -797,13 +810,11 @@ def sso_after_process_request(request, login, consent_obtained=False, logger.debug('validateRequestMsg %s', login.dump()) except lasso.LoginRequestDeniedError: logger.warning('access denied due to LoginRequestDeniedError') - set_saml2_response_responder_status_code(login.response, - lasso.SAML2_STATUS_CODE_REQUEST_DENIED) + set_saml2_response_responder_status_code(login.response, lasso.SAML2_STATUS_CODE_REQUEST_DENIED) return finish_sso(request, login, user=user) except lasso.LoginFederationNotFoundError: logger.warning('access denied due to LoginFederationNotFoundError') - set_saml2_response_responder_status_code(login.response, - lasso.SAML2_STATUS_CODE_REQUEST_DENIED) + set_saml2_response_responder_status_code(login.response, lasso.SAML2_STATUS_CODE_REQUEST_DENIED) return finish_sso(request, login, user=user) login.response.consent = consent_value @@ -836,8 +847,7 @@ def return_login_response(request, login): else: raise NotImplementedError() provider = LibertyProvider.objects.get(entity_id=login.remoteProviderId) - return return_saml2_response(request, login, - title=_('You are being redirected to "%s"') % provider.name) + return return_saml2_response(request, login, title=_('You are being redirected to "%s"') % provider.name) def finish_sso(request, login, user=None, return_profile=False): @@ -851,9 +861,10 @@ def finish_sso(request, login, user=None, return_profile=False): def save_artifact(request, login): '''Remember an artifact message for later retrieving''' - LibertyArtifact(artifact=login.artifact, - content=login.artifactMessage.decode('utf-8'), - provider_id=login.remoteProviderId).save() + LibertyArtifact( + artifact=login.artifact, + content=login.artifactMessage.decode('utf-8'), + provider_id=login.remoteProviderId).save() logger.debug('artifact saved') @@ -880,8 +891,7 @@ def artifact(request): try: login.processRequestMsg(soap_message) except (lasso.ProfileUnknownProviderError, lasso.ParamError): - if not load_provider(request, login.remoteProviderId, - server=login.server): + if not load_provider(request, login.remoteProviderId, server=login.server): logger.warning('provider loading failure') try: login.processRequestMsg(soap_message) @@ -890,16 +900,17 @@ def artifact(request): else: logger.debug('reloading artifact') reload_artifact(login) - except: + except Exception: logger.exception('resolve error') try: login.buildResponseMsg(None) logger.debug('resolve response %s' % login.msgBody) - except: + except Exception: logger.exception('resolve error') - return soap_fault(request, - faultcode='soap:Server', - faultstring='Internal Server Error') + return soap_fault( + request, + faultcode='soap:Server', + faultstring='Internal Server Error') logger.debug('treatment ended, return answer') return return_saml_soap_response(login) @@ -915,37 +926,33 @@ def check_delegated_authentication_permission(request): def idp_sso(request, provider_id=None, return_profile=False): '''Initiate an SSO toward provider_id without a prior AuthnRequest ''' - User = get_user_model() if not provider_id: provider_id = request.POST.get('provider_id') if not provider_id: - return error_redirect(request, - N_('missing provider identifier')) + return error_redirect(request, N_('missing provider identifier')) logger.debug('start of an idp initiated sso toward %s', provider_id) server = create_server(request) login = lasso.Login(server) - liberty_provider = load_provider(request, provider_id, - server=login.server) + liberty_provider = load_provider(request, provider_id, server=login.server) if not liberty_provider: return error_redirect(request, N_('provider %r is unknown'), provider_id) username = request.POST.get('username') if username: if not check_delegated_authentication_permission(request): - return error_redirect(request, - N_('%r tried to log as %r on %r but was forbidden'), - request.user, username, provider_id) + return error_redirect( + request, + N_('%r tried to log as %r on %r but was forbidden'), + request.user, username, provider_id) try: user = User.objects.get_by_natural_key(username=username) except User.DoesNotExist: - return error_redirect(request, - N_('you cannot login as %r as it does not exist'), username) + return error_redirect(request, N_('you cannot login as %r as it does not exist'), username) else: user = request.user policy = get_sp_options_policy(liberty_provider) # Control assertion consumer binding if not policy: - return error_redirect(request, - N_('missing service provider policy')) + return error_redirect(request, N_('missing service provider policy')) nid_format = policy.default_name_id_format if needs_persistence(nid_format): load_federation(request, get_entity_id(request, reverse(metadata)), login, user) @@ -958,8 +965,7 @@ def idp_sso(request, provider_id=None, return_profile=False): elif binding == 'post': login.request.protocolBinding = lasso.SAML2_METADATA_BINDING_POST else: - return error_redirect(request, - N_('unknown binding %r') % binding) + return error_redirect(request, N_('unknown binding %r') % binding) # Control nid format policy # XXX: if a federation exist, we should use transient login.request.nameIdPolicy.format = nidformat_to_saml2_urn(nid_format) @@ -970,9 +976,9 @@ def idp_sso(request, provider_id=None, return_profile=False): logger.debug('binding %r', binding) logger.debug('authentication request initialized toward provider_id %r', provider_id) - return sso_after_process_request(request, login, - consent_obtained=False, user=user, - nid_format=nid_format, return_profile=return_profile) + return sso_after_process_request(request, login, consent_obtained=False, + user=user, nid_format=nid_format, + return_profile=return_profile) @never_cache @@ -1007,8 +1013,7 @@ def finish_slo(request): logger.warning('partial logout') logout.buildResponseMsg() provider = LibertyProvider.objects.get(entity_id=logout.remoteProviderId) - return return_saml2_response(request, logout, - title=_('You are being redirected to "%s"') % provider.name) + return return_saml2_response(request, logout, title=_('You are being redirected to "%s"') % provider.name) def return_logout_error(request, logout, error): @@ -1019,8 +1024,7 @@ def return_logout_error(request, logout, error): logout.buildResponseMsg() logger.debug('returned an error message on logout: %s', error) provider = LibertyProvider.objects.get(entity_id=logout.remoteProviderId) - return return_saml2_response(request, logout, - title=_('You are being redirected to "%s"') % provider.name) + return return_saml2_response(request, logout, title=_('You are being redirected to "%s"') % provider.name) def process_logout_request(request, message, binding): @@ -1036,12 +1040,10 @@ def process_logout_request(request, message, binding): except (lasso.ServerProviderNotFoundError, lasso.ProfileUnknownProviderError): logger.debug('loading provider %s', logout.remoteProviderId) - p = load_provider(request, logout.remoteProviderId, - server=logout.server) + p = load_provider(request, logout.remoteProviderId, server=logout.server) if not p: logger.warning('slo unknown provider %s', logout.remoteProviderId) - return logout, return_logout_error(request, logout, - AUTHENTIC_STATUS_CODE_UNKNOWN_PROVIDER) + return logout, return_logout_error(request, logout, AUTHENTIC_STATUS_CODE_UNKNOWN_PROVIDER) policy = get_sp_options_policy(p) # we do not verify authn request, why verify logout requests... if not policy.authn_request_signed: @@ -1049,15 +1051,13 @@ def process_logout_request(request, message, binding): logout.processRequestMsg(message) except lasso.DsError: logger.warning('slo signature error') - return logout, return_logout_error(request, logout, - lasso.LIB_STATUS_CODE_INVALID_SIGNATURE) + return logout, return_logout_error(request, logout, lasso.LIB_STATUS_CODE_INVALID_SIGNATURE) except Exception as e: logger.warning('slo unknown error when processing a request: %s', e) return logout, HttpResponseBadRequest('Invalid logout request', content_type='text/plain') if binding != 'SOAP' and not check_destination(request, logout.request): logger.warning('slo wrong or absent destination') - return logout, return_logout_error(request, logout, - AUTHENTIC_STATUS_CODE_MISSING_DESTINATION) + return logout, return_logout_error(request, logout, AUTHENTIC_STATUS_CODE_MISSING_DESTINATION) return logout, None @@ -1085,8 +1085,7 @@ def logout_synchronous_other_backends(request, logout, django_sessions_keys): for backend in backends: ok = ok and backend.can_synchronous_logout(django_sessions_keys) if not ok: - return return_logout_error(request, logout, - lasso.SAML2_STATUS_CODE_UNSUPPORTED_BINDING) + return return_logout_error(request, logout, lasso.SAML2_STATUS_CODE_UNSUPPORTED_BINDING) logger.debug('treatments ended') return None @@ -1098,11 +1097,9 @@ def get_only_last_session(issuer_id, provider_id, name_id, session_indexes): Enumerate all emitted assertions for the given session, and for each provider only keep the more recent one. """ - lib_session1 = LibertySession.get_for_nameid_and_session_indexes( - issuer_id, provider_id, name_id, session_indexes) + lib_session1 = LibertySession.get_for_nameid_and_session_indexes(issuer_id, provider_id, name_id, session_indexes) django_session_keys = [s.django_session_key for s in lib_session1] - lib_session = LibertySession.objects.filter( - django_session_key__in=django_session_keys) + lib_session = LibertySession.objects.filter(django_session_key__in=django_session_keys) providers = set([s.provider_id for s in lib_session]) result = [] for provider in providers: @@ -1118,7 +1115,10 @@ def get_only_last_session(issuer_id, provider_id, name_id, session_indexes): def build_session_dump(liberty_sessions): '''Build a session dump from a list of pairs (provider_id,assertion_content)''' - session = [u''] + session = [ + u'', + ] for liberty_session in liberty_sessions: session.append(u'. + from django.conf.urls import url from . import views diff --git a/src/authentic2/idp/saml/views.py b/src/authentic2/idp/saml/views.py index 97c066fe..2c3d9cc9 100644 --- a/src/authentic2/idp/saml/views.py +++ b/src/authentic2/idp/saml/views.py @@ -1,3 +1,19 @@ +# 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 . + from django.utils.translation import ugettext as _ from django.core.urlresolvers import reverse from django.views.generic import DeleteView, View @@ -8,9 +24,11 @@ from django.contrib import messages from authentic2.saml.models import LibertyFederation + class FederationCreateView(View): pass + class FederationDeleteView(DeleteView): model = LibertyFederation @@ -28,8 +46,7 @@ class FederationDeleteView(DeleteView): return HttpResponseRedirect(self.get_success_url()) def get_success_url(self): - return self.request.POST.get(REDIRECT_FIELD_NAME, - reverse('auth_homepage')) + return self.request.POST.get(REDIRECT_FIELD_NAME, reverse('auth_homepage')) delete_federation = FederationDeleteView.as_view() diff --git a/src/authentic2/idp/signals.py b/src/authentic2/idp/signals.py index fe446830..c20c99ea 100644 --- a/src/authentic2/idp/signals.py +++ b/src/authentic2/idp/signals.py @@ -1,3 +1,19 @@ +# 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 . + from django.dispatch import Signal '''authorize_decision @@ -5,10 +21,9 @@ Expect a dictionnaries as return with: - the authorization decision e.g. dic['authz'] = True or False - optionnaly a message e.g. dic['message'] = message ''' -authorize_service = Signal(providing_args = ["request", "user", "audience", - "attributes"]) +authorize_service = Signal(providing_args=["request", "user", "audience", "attributes"]) '''avoid_consent Expect a boolean e.g. dic['avoid_consent'] = True or False ''' -avoid_consent = Signal(providing_args = ["request", "user", "audience"]) +avoid_consent = Signal(providing_args=["request", "user", "audience"]) diff --git a/src/authentic2/idp/templatetags/__init__.py b/src/authentic2/idp/templatetags/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2/idp/templatetags/breadcrumbs.py b/src/authentic2/idp/templatetags/breadcrumbs.py deleted file mode 100644 index 5d23851b..00000000 --- a/src/authentic2/idp/templatetags/breadcrumbs.py +++ /dev/null @@ -1,134 +0,0 @@ -# This is a copy of http://djangosnippets.org/snippets/1289/ -# -# it provides two template tags to use in HTML templates: breadcrumb and -# breadcrumb_url. -# -# The first allows creating of simple url, with the text portion and url -# portion. Or only unlinked text (as the last item in breadcrumb trail for -# example). The second, can actually take the named url with arguments. -# Additionally it takes a title as the first argument. -# -# Initial Author: Andriy Drozdyuk - - -from django import template -from django.template import loader, Node, Variable -from django.utils.encoding import smart_str, smart_unicode -from django.template.defaulttags import url -from django.template import VariableDoesNotExist - -register = template.Library() - -@register.tag -def breadcrumb(parser, token): - """ - Renders the breadcrumb. - Examples: - {% breadcrumb "Title of breadcrumb" url_var %} - {% breadcrumb context_var url_var %} - {% breadcrumb "Just the title" %} - {% breadcrumb just_context_var %} - - Parameters: - -First parameter is the title of the crumb, - -Second (optional) parameter is the url variable to link to, produced by url tag, i.e.: - {% url 'person_detail' object.id as person_url %} - then: - {% breadcrumb person.name person_url %} - - @author Andriy Drozdyuk - """ - return BreadcrumbNode(token.split_contents()[1:]) - - -@register.tag -def breadcrumb_url(parser, token): - """ - Same as breadcrumb - but instead of url context variable takes in all the - arguments URL tag takes. - {% breadcrumb "Title of breadcrumb" person_detail person.id %} - {% breadcrumb person.name person_detail person.id %} - """ - - bits = token.split_contents() - if len(bits)==2: - return breadcrumb(parser, token) - - # Extract our extra title parameter - title = bits.pop(1) - token.contents = ' '.join(bits) - - url_node = url(parser, token) - - return UrlBreadcrumbNode(title, url_node) - - -class BreadcrumbNode(Node): - def __init__(self, vars): - """ - First var is title, second var is url context variable - """ - self.vars = map(Variable,vars) - - def render(self, context): - title = self.vars[0].var - - if title.find("'")==-1 and title.find('"')==-1: - try: - val = self.vars[0] - title = val.resolve(context) - except: - title = '' - - else: - title=title.strip("'").strip('"') - title=smart_unicode(title) - - url = None - - if len(self.vars)>1: - val = self.vars[1] - try: - url = val.resolve(context) - except VariableDoesNotExist: - url = None - - return create_crumb(title, url) - - -class UrlBreadcrumbNode(Node): - def __init__(self, title, url_node): - self.title = Variable(title) - self.url_node = url_node - - def render(self, context): - title = self.title.var - - if title.find("'")==-1 and title.find('"')==-1: - try: - val = self.title - title = val.resolve(context) - except: - title = '' - else: - title=title.strip("'").strip('"') - title=smart_unicode(title) - - url = self.url_node.render(context) - return create_crumb(title, url) - - -def create_crumb(title, url=None): - """ - Helper function - """ - crumb = """""" \ - """ > """ \ - """""" - if url: - crumb = "%s %s" % (crumb, url, title) - else: - crumb = "%s %s" % (crumb, title) - - return crumb diff --git a/src/authentic2/idp/urls.py b/src/authentic2/idp/urls.py index 3ec00bf0..9c560768 100644 --- a/src/authentic2/idp/urls.py +++ b/src/authentic2/idp/urls.py @@ -1,9 +1,23 @@ +# 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 . + from django.conf.urls import url -from authentic2.idp.interactions import consent_federation, consent_attributes +from authentic2.idp.interactions import consent_federation urlpatterns = [ url(r'^consent_federation', consent_federation, name='a2-consent-federation'), - url(r'^consent_attributes', consent_attributes, - name='a2-consent-attributes') ] diff --git a/src/authentic2/idp/utils.py b/src/authentic2/idp/utils.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2/idp/views.py b/src/authentic2/idp/views.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2/ldap_utils.py b/src/authentic2/ldap_utils.py index 65e5562c..61d2317e 100644 --- a/src/authentic2/ldap_utils.py +++ b/src/authentic2/ldap_utils.py @@ -1,4 +1,19 @@ -# -*- coding: utf-8 -*- +# 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 string diff --git a/src/authentic2/log_filters.py b/src/authentic2/log_filters.py index 5b533d97..b31b8a68 100644 --- a/src/authentic2/log_filters.py +++ b/src/authentic2/log_filters.py @@ -1,6 +1,23 @@ +# 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 logging from django.utils import six + class RequestContextFilter(logging.Filter): DEFAULT_USERNAME = '-' DEFAULT_IP = '-' @@ -16,7 +33,7 @@ class RequestContextFilter(logging.Filter): user = self.DEFAULT_USERNAME ip = self.DEFAULT_IP request_id = self.DEFAULT_REQUEST_ID - if not request is None: + if request is not None: if hasattr(request, 'user') and request.user.is_authenticated(): user = six.text_type(request.user) ip = request.META.get('REMOTE_ADDR', self.DEFAULT_IP) diff --git a/src/authentic2/logger.py b/src/authentic2/logger.py index 55ff46b9..fba62579 100644 --- a/src/authentic2/logger.py +++ b/src/authentic2/logger.py @@ -1,5 +1,22 @@ +# 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 logging + class SettingsLogLevel(int): def __new__(cls, default_log_level, debug_setting='DEBUG'): return super(SettingsLogLevel, cls).__new__( @@ -9,6 +26,7 @@ class SettingsLogLevel(int): self.debug_setting = debug_setting super(SettingsLogLevel, self).__init__() + class DjangoLogger(logging.getLoggerClass()): def getEffectiveLevel(self): level = super(DjangoLogger, self).getEffectiveLevel() @@ -21,6 +39,7 @@ class DjangoLogger(logging.getLoggerClass()): logging.setLoggerClass(DjangoLogger) + class DjangoRootLogger(DjangoLogger, logging.RootLogger): pass diff --git a/src/authentic2/management/commands/clean-unused-accounts.py b/src/authentic2/management/commands/clean-unused-accounts.py index 0340b15a..720e58ec 100644 --- a/src/authentic2/management/commands/clean-unused-accounts.py +++ b/src/authentic2/management/commands/clean-unused-accounts.py @@ -1,3 +1,19 @@ +# 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 . + from __future__ import print_function import logging @@ -13,13 +29,16 @@ from authentic2.models import DeletedUser from django.conf import settings +logger = logging.getLogger(__name__) + + def print_table(table): col_width = [max(len(x) for x in col) for col in zip(*table)] for line in table: - line = u"| " + u" | ".join(u"{0:>{1}}".format(x, col_width[i]) - for i, x in enumerate(line)) + u" |" + line = u"| " + u" | ".join(u"{0:>{1}}".format(x, col_width[i]) for i, x in enumerate(line)) + u" |" print(line) + class Command(BaseCommand): help = '''Clean unused accounts''' @@ -48,11 +67,10 @@ class Command(BaseCommand): ) def handle(self, *args, **options): - log = logging.getLogger(__name__) try: self.clean_unused_acccounts(*args, **options) - except: - log.exception('failure while cleaning unused accounts') + except Exception: + logger.exception('failure while cleaning unused accounts') def clean_unused_acccounts(self, *args, **options): if options['period'] < 1: @@ -71,19 +89,18 @@ class Command(BaseCommand): elif options['verbosity'] == '3': logging.basicConfig(level=logging.DEBUG) - log = logging.getLogger(__name__) n = now().replace(hour=0, minute=0, second=0, microsecond=0) self.fake = options['fake'] self.from_email = options['from_email'] if self.fake: - log.info('fake call to clean-unused-accounts') + logger.info('fake call to clean-unused-accounts') users = get_user_model().objects.all() if options['filter']: for f in options['filter']: key, value = f.split('=', 1) try: users = users.filter(**{key: value}) - except: + except Exception: raise CommandError('invalid --filter %s' % f) if options['alert_thresholds']: alert_thresholds = options['alert_thresholds'] @@ -91,50 +108,49 @@ class Command(BaseCommand): try: alert_thresholds = map(int, alert_thresholds) except ValueError: - raise CommandError('alert_thresholds must be a comma ' - 'separated list of integers') + raise CommandError('alert_thresholds must be a comma separated list of integers') for threshold in alert_thresholds: if not (0 < threshold < clean_threshold): - raise CommandError('alert-threshold must a positive integer ' - 'inferior to clean-threshold: 0 < %d < %d' % ( - threshold, clean_threshold)) + raise CommandError( + 'alert-threshold must a positive integer inferior to clean-threshold: 0 < %d < %d' % ( + threshold, clean_threshold)) for threshold in alert_thresholds: a = n - datetime.timedelta(days=threshold) - b = n - datetime.timedelta(days=threshold-options['period']) + b = n - datetime.timedelta(days=threshold - options['period']) for user in users.filter(last_login__lt=b, last_login__gte=a): - log.info('%s last login %d days ago, sending alert', user, threshold) - self.send_alert(user, threshold, clean_threshold-threshold) + logger.info('%s last login %d days ago, sending alert', user, threshold) + self.send_alert(user, threshold, clean_threshold - threshold) threshold = n - datetime.timedelta(days=clean_threshold) for user in users.filter(last_login__lt=threshold): d = n - user.last_login - log.info('%s last login %d days ago, deleting user', user, d.days) + logger.info('%s last login %d days ago, deleting user', user, d.days) self.delete_user(user, clean_threshold) - def send_alert(self, user, threshold, clean_threshold): - ctx = { 'user': user, 'threshold': threshold, - 'clean_threshold': clean_threshold } + ctx = { + 'user': user, + 'threshold': threshold, + 'clean_threshold': clean_threshold + } self.send_mail('authentic2/unused_account_alert', user, ctx) - def send_mail(self, prefix, user, ctx): - log = logging.getLogger(__name__) - if not user.email: - log.debug('%s has no email, no mail sent', user) + logger.debug('%s has no email, no mail sent', user) subject = render_to_string(prefix + '_subject.txt', ctx).strip() body = render_to_string(prefix + '_body.txt', ctx) if not self.fake: try: - log.debug('sending mail to %s', user.email) + logger.debug('sending mail to %s', user.email) send_mail(subject, body, self.from_email, [user.email]) - except: - log.exception('email sending failure') - + except Exception: + logger.exception('email sending failure') def delete_user(self, user, threshold): - ctx = { 'user': user, 'threshold': threshold } - self.send_mail('authentic2/unused_account_delete', user, - ctx) + ctx = { + 'user': user, + 'threshold': threshold + } + self.send_mail('authentic2/unused_account_delete', user, ctx) if not self.fake: DeletedUser.objects.delete_user(user) diff --git a/src/authentic2/management/commands/export_site.py b/src/authentic2/management/commands/export_site.py index 58c6ddda..9cd533f2 100644 --- a/src/authentic2/management/commands/export_site.py +++ b/src/authentic2/management/commands/export_site.py @@ -1,10 +1,25 @@ +# 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 json import sys from django.core.management.base import BaseCommand from authentic2.data_transfer import export_site -from django_rbac.utils import get_role_model class Command(BaseCommand): diff --git a/src/authentic2/management/commands/import_site.py b/src/authentic2/management/commands/import_site.py index 820dbf97..d18bd28b 100644 --- a/src/authentic2/management/commands/import_site.py +++ b/src/authentic2/management/commands/import_site.py @@ -1,3 +1,19 @@ +# 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 contextlib import json import sys diff --git a/src/authentic2/management/commands/load-ldif.py b/src/authentic2/management/commands/load-ldif.py index adac2eab..aaa85433 100644 --- a/src/authentic2/management/commands/load-ldif.py +++ b/src/authentic2/management/commands/load-ldif.py @@ -1,3 +1,19 @@ +# 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 argparse import logging import json @@ -5,9 +21,9 @@ import json from django.core.management.base import BaseCommand from django.contrib.auth import get_user_model +from django.db.transaction import atomic -from authentic2.compat import atomic from authentic2.hashers import olap_password_to_dj from authentic2.models import Attribute @@ -31,7 +47,6 @@ class DjangoUserLDIFParser(ldif.LDIFParser): self.callback = d.get('callback') ldif.LDIFParser.__init__(self, *args, **kwargs) - def handle(self, dn, entry): User = get_user_model() if self.object_class not in entry['objectClass']: @@ -63,7 +78,7 @@ class DjangoUserLDIFParser(ldif.LDIFParser): m.extend(self.callback(u, dn, entry, self.options, d)) if 'username' not in d: self.log.warning('cannot load dn %s, username cannot be initialized from the field %s', - dn, self.options['username']) + dn, self.options['username']) return try: old = User.objects.get(username=d['username']) @@ -77,7 +92,7 @@ class DjangoUserLDIFParser(ldif.LDIFParser): def parse(self, *args, **kwargs): ldif.LDIFParser.parse(self, *args, **kwargs) if self.options['result']: - with file(self.options['result'], 'w') as f: + with open(self.options['result'], 'w') as f: json.dump(self.json, f) @@ -86,10 +101,9 @@ class ExtraAttributeAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): ldap_attribute, django_attribute = values try: - attribute = Attribute.objects.get(name=django_attribute) + Attribute.objects.get(name=django_attribute) except Attribute.DoesNotExist: - raise argparse.ArgumentTypeError( - 'django attribute %s does not exist' % django_attribute) + raise argparse.ArgumentTypeError('django attribute %s does not exist' % django_attribute) res = getattr(namespace, self.dest, {}) res[ldap_attribute] = django_attribute setattr(namespace, self.dest, res) @@ -138,7 +152,7 @@ class Command(BaseCommand): options['verbosity'] = int(options['verbosity']) ldif_files = options.pop('ldif_file') for arg in ldif_files: - f = file(arg) + f = open(arg) parser = DjangoUserLDIFParser(f, options=options, command=self) parser.parse() if not options['fake']: diff --git a/src/authentic2/management/commands/resetpassword.py b/src/authentic2/management/commands/resetpassword.py index 81cc4c41..b9e45b6e 100644 --- a/src/authentic2/management/commands/resetpassword.py +++ b/src/authentic2/management/commands/resetpassword.py @@ -1,3 +1,19 @@ +# 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 getpass from django.contrib.auth import get_user_model @@ -7,6 +23,10 @@ from django.db import DEFAULT_DB_ALIAS from authentic2.utils import generate_password from authentic2.models import PasswordReset + +User = get_user_model() + + class Command(BaseCommand): help = "Reset a user's password for django.contrib.auth." @@ -29,13 +49,11 @@ class Command(BaseCommand): if not username: username = getpass.getuser() - UserModel = get_user_model() - try: - u = UserModel._default_manager.using(options.get('database')).get(**{ - UserModel.USERNAME_FIELD: username - }) - except UserModel.DoesNotExist: + u = User._default_manager.using(options.get('database')).get(**{ + User.USERNAME_FIELD: username + }) + except User.DoesNotExist: raise CommandError("user '%s' does not exist" % username) p1 = generate_password() @@ -43,5 +61,7 @@ class Command(BaseCommand): u.set_password(p1) u.save() PasswordReset.objects.get_or_create(user=u) - return "Password changed successfully for user '%s', on next login he will be forced to change its password." % u + return ( + 'Password changed successfully for user "%s", on next login he ' + 'will be forced to change its password.' % u) diff --git a/src/authentic2/management/commands/slapd-shell.py b/src/authentic2/management/commands/slapd-shell.py index bc991d9b..eb7eaa98 100644 --- a/src/authentic2/management/commands/slapd-shell.py +++ b/src/authentic2/management/commands/slapd-shell.py @@ -1,3 +1,19 @@ +# 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 . + from __future__ import print_function import logging @@ -11,7 +27,6 @@ from ldif import LDIFWriter from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand from django.utils import six -from optparse import make_option COMMAND = 1 ATTR = 2 @@ -24,13 +39,14 @@ MAPPING = { 'email': 'mail', } + def unescape_filter_chars(s): return re.sub(r'\\..', lambda s: s.group()[1:].decode('hex'), s) + class Command(BaseCommand): help = 'OpenLDAP shell backend' - def ldap(self, command, attrs): self.logger.debug('received command %s %s', command, attrs) if command == 'SEARCH': diff --git a/src/authentic2/management/commands/sync-ldap-users.py b/src/authentic2/management/commands/sync-ldap-users.py index c5890304..cbef7c5b 100644 --- a/src/authentic2/management/commands/sync-ldap-users.py +++ b/src/authentic2/management/commands/sync-ldap-users.py @@ -1,14 +1,30 @@ +# 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 . + try: import ldap - from ldap.filter import filter_format + from ldap.filter import filter_format # noqa: F401 except ImportError: ldap = None -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from authentic2.backends.ldap_backend import LDAPBackend -class Command(BaseCommand): +class Command(BaseCommand): def handle(self, *args, **kwargs): list(LDAPBackend.get_users()) diff --git a/src/authentic2/manager/__init__.py b/src/authentic2/manager/__init__.py index 2b05ca5c..1efb4bd6 100644 --- a/src/authentic2/manager/__init__.py +++ b/src/authentic2/manager/__init__.py @@ -1 +1,17 @@ +# 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 . + default_app_config = 'authentic2.manager.apps.AppConfig' diff --git a/src/authentic2/manager/app_settings.py b/src/authentic2/manager/app_settings.py index 3bcad781..587c99c2 100644 --- a/src/authentic2/manager/app_settings.py +++ b/src/authentic2/manager/app_settings.py @@ -1,3 +1,19 @@ +# 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 sys diff --git a/src/authentic2/manager/apps.py b/src/authentic2/manager/apps.py index c504e6bb..3dd6413c 100644 --- a/src/authentic2/manager/apps.py +++ b/src/authentic2/manager/apps.py @@ -1,3 +1,19 @@ +# 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 . + from django.apps import AppConfig @@ -8,7 +24,6 @@ class AppConfig(AppConfig): def ready(self): from django.db.models.signals import post_save from django_rbac.utils import get_ou_model - from django_select2 import conf post_save.connect( self.post_save_ou, diff --git a/src/authentic2/manager/fields.py b/src/authentic2/manager/fields.py index 09d68ba0..65f1d524 100644 --- a/src/authentic2/manager/fields.py +++ b/src/authentic2/manager/fields.py @@ -1,3 +1,19 @@ +# 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 . + from django import forms from . import widgets diff --git a/src/authentic2/manager/forms.py b/src/authentic2/manager/forms.py index 1708290f..3f851ce7 100644 --- a/src/authentic2/manager/forms.py +++ b/src/authentic2/manager/forms.py @@ -1,3 +1,19 @@ +# 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 hashlib import smtplib import logging @@ -5,12 +21,12 @@ import logging from django.utils.translation import ugettext_lazy as _, pgettext from django import forms from django.contrib.contenttypes.models import ContentType +from django.contrib.auth import get_user_model from django.db.models.query import Q from django.utils import six from django.utils.text import slugify from django.core.exceptions import ValidationError -from authentic2.compat import get_user_model from authentic2.passwords import generate_password from authentic2.utils import send_templated_mail from authentic2.forms.fields import NewPasswordField, CheckPasswordField @@ -19,7 +35,7 @@ from django_rbac.models import Operation from django_rbac.utils import get_ou_model, get_role_model, get_permission_model from django_rbac.backends import DjangoRBACBackend -from authentic2.forms import BaseUserForm +from authentic2.forms.profile import BaseUserForm from authentic2.models import PasswordReset from authentic2.utils import import_module_or_class from authentic2.a2_rbac.utils import get_default_ou @@ -28,6 +44,7 @@ from authentic2 import app_settings as a2_app_settings from . import fields, app_settings, utils +User = get_user_model() logger = logging.getLogger(__name__) @@ -194,15 +211,14 @@ class UserEditForm(LimitQuerysetFormMixin, CssClass, BaseUserForm): self.data._mutable = False def clean(self): - if (self.instance.has_usable_password() and ( - 'username' in self.fields or - 'email' in self.fields)): + if (self.instance.has_usable_password() + and ('username' in self.fields + or 'email' in self.fields)): if not self.cleaned_data.get('username') and \ not self.cleaned_data.get('email'): raise forms.ValidationError( _('You must set a username or an email.')) - User = get_user_model() if self.cleaned_data.get('email'): qs = User.objects.all() ou = getattr(self, 'ou', None) @@ -226,7 +242,7 @@ class UserEditForm(LimitQuerysetFormMixin, CssClass, BaseUserForm): }) class Meta: - model = get_user_model() + model = User exclude = ('is_staff', 'groups', 'user_permissions', 'last_login', 'date_joined', 'password') @@ -251,17 +267,17 @@ class UserChangePasswordForm(CssClass, forms.ModelForm): def clean(self): super(UserChangePasswordForm, self).clean() - if (self.require_password and - not self.cleaned_data.get('generate_password') and - not self.cleaned_data.get('password1') and - not self.cleaned_data.get('send_password_reset')): + if (self.require_password + and not self.cleaned_data.get('generate_password') + and not self.cleaned_data.get('password1') + and not self.cleaned_data.get('send_password_reset')): raise forms.ValidationError( _('You must choose password generation or type a new' ' one or send a password reset mail')) - if (not self.has_email() and - (self.cleaned_data.get('send_mail') or - self.cleaned_data.get('generate_password' or - self.cleaned_data.get('send_password_reset')))): + if (not self.has_email() + and (self.cleaned_data.get('send_mail') + or self.cleaned_data.get('generate_password') + or self.cleaned_data.get('send_password_reset'))): raise forms.ValidationError( _('User does not have a mail, we cannot send the ' 'informations to him.')) @@ -310,7 +326,7 @@ class UserChangePasswordForm(CssClass, forms.ModelForm): required=False) class Meta: - model = get_user_model() + model = User fields = () @@ -340,13 +356,13 @@ class UserAddForm(UserChangePasswordForm, UserEditForm): # check if this account is going to be real online account, i.e. with a # password, it it's the case complain that there is no identifiers. has_password = ( - self.cleaned_data.get('new_password1') or - self.cleaned_data.get('generate_password') or - self.cleaned_data.get('send_password_reset')) + self.cleaned_data.get('new_password1') + or self.cleaned_data.get('generate_password') + or self.cleaned_data.get('send_password_reset')) - if (has_password and - not self.cleaned_data.get('username') and - not self.cleaned_data.get('email')): + if (has_password + and not self.cleaned_data.get('username') + and not self.cleaned_data.get('email')): raise forms.ValidationError( _('You must set a username or an email to set a password or send an activation link.')) @@ -383,7 +399,7 @@ class UserAddForm(UserChangePasswordForm, UserEditForm): return user class Meta: - model = get_user_model() + model = User fields = '__all__' exclude = ('ou',) @@ -694,3 +710,8 @@ class UserChangeEmailForm(CssClass, FormWithRequest, forms.ModelForm): class Meta: fields = () + + +class SiteImportForm(forms.Form): + site_json = forms.FileField( + label=_('Site Export File')) diff --git a/src/authentic2/manager/models.py b/src/authentic2/manager/models.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2/manager/ou_views.py b/src/authentic2/manager/ou_views.py index 096df05b..a9904a70 100644 --- a/src/authentic2/manager/ou_views.py +++ b/src/authentic2/manager/ou_views.py @@ -1,3 +1,19 @@ +# 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 json from django_rbac.utils import get_ou_model diff --git a/src/authentic2/manager/resources.py b/src/authentic2/manager/resources.py index 8d3d9b3c..539eea80 100644 --- a/src/authentic2/manager/resources.py +++ b/src/authentic2/manager/resources.py @@ -1,11 +1,30 @@ +# 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 . + +from django.contrib.auth import get_user_model from django.utils import six + from import_export.resources import ModelResource from import_export.fields import Field from import_export.widgets import Widget -from authentic2.compat import get_user_model from authentic2.a2_rbac.models import Role +User = get_user_model() + class ListWidget(Widget): def clean(self, value): @@ -27,7 +46,7 @@ class UserResource(ModelResource): return ', '.join(map(six.text_type, result)) class Meta: - model = get_user_model() + model = User exclude = ('password', 'user_permissions', 'is_staff', 'is_superuser', 'groups') export_order = ('ou', 'uuid', 'id', 'username', 'email', diff --git a/src/authentic2/manager/role_views.py b/src/authentic2/manager/role_views.py index 30d65d39..7df448a0 100644 --- a/src/authentic2/manager/role_views.py +++ b/src/authentic2/manager/role_views.py @@ -1,20 +1,33 @@ +# 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 json from django.core.exceptions import PermissionDenied from django.utils.translation import ugettext_lazy as _ -from django.views.generic import ListView, FormView, TemplateView -from django.views.generic.edit import FormMixin, DeleteView +from django.views.generic import FormView, TemplateView from django.views.generic.detail import SingleObjectMixin from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.db.models.query import Q from django.db.models import Count from django.core.urlresolvers import reverse -from django.http import Http404 from django.contrib.auth import get_user_model -from django_rbac.utils import get_role_model, get_permission_model, \ - get_role_parenting_model, get_ou_model +from django_rbac.utils import get_role_model, get_permission_model, get_ou_model from authentic2.utils import redirect from authentic2 import hooks, data_transfer @@ -38,9 +51,8 @@ class RolesMixin(object): # only non role-admin roles, they are accessed through the # RoleManager views if not self.admin_roles: - qs = qs.filter(Q(admin_scope_ct__isnull=True) | - Q(admin_scope_ct=permission_ct, - admin_scope_id__in=permission_qs)) + qs = qs.filter( + Q(admin_scope_ct__isnull=True) | Q(admin_scope_ct=permission_ct, admin_scope_id__in=permission_qs)) if not self.service_roles: qs = qs.filter(service__isnull=True) return qs @@ -177,14 +189,11 @@ class RoleMembersView(views.HideOUColumnMixin, RoleViewMixin, views.BaseSubTable def get_context_data(self, **kwargs): ctx = super(RoleMembersView, self).get_context_data(**kwargs) ctx['children'] = views.filter_view(self.request, - self.object.children(include_self=False, - annotate=True)) - ctx['parents'] = views.filter_view(self.request, - self.object.parents(include_self=False, - annotate=True)) + self.object.children(include_self=False, annotate=True)) + ctx['parents'] = views.filter_view(self.request, self.object.parents(include_self=False, annotate=True)) ctx['admin_roles'] = views.filter_view(self.request, - self.object.get_admin_role().children( - include_self=False, annotate=True)) + self.object.get_admin_role().children(include_self=False, + annotate=True)) return ctx members = RoleMembersView.as_view() @@ -373,7 +382,7 @@ remove_parent = RoleRemoveParentView.as_view() class RoleAddAdminRoleView(views.AjaxFormViewMixin, views.TitleMixin, - views.PermissionMixin, SingleObjectMixin, FormView): + views.PermissionMixin, SingleObjectMixin, FormView): title = _('Add admin role') model = get_role_model() form_class = forms.RolesForm @@ -396,8 +405,9 @@ class RoleAddAdminRoleView(views.AjaxFormViewMixin, views.TitleMixin, add_admin_role = RoleAddAdminRoleView.as_view() -class RoleRemoveAdminRoleView(views.TitleMixin, views.AjaxFormViewMixin, SingleObjectMixin, - views.PermissionMixin, TemplateView): +class RoleRemoveAdminRoleView(views.TitleMixin, views.AjaxFormViewMixin, + SingleObjectMixin, views.PermissionMixin, + TemplateView): title = _('Remove admin role') model = get_role_model() success_url = '../..' @@ -424,7 +434,7 @@ remove_admin_role = RoleRemoveAdminRoleView.as_view() class RoleAddAdminUserView(views.AjaxFormViewMixin, views.TitleMixin, - views.PermissionMixin, SingleObjectMixin, FormView): + views.PermissionMixin, SingleObjectMixin, FormView): title = _('Add admin user') model = get_role_model() form_class = forms.UsersForm @@ -447,8 +457,9 @@ class RoleAddAdminUserView(views.AjaxFormViewMixin, views.TitleMixin, add_admin_user = RoleAddAdminUserView.as_view() -class RoleRemoveAdminUserView(views.TitleMixin, views.AjaxFormViewMixin, SingleObjectMixin, - views.PermissionMixin, TemplateView): +class RoleRemoveAdminUserView(views.TitleMixin, views.AjaxFormViewMixin, + SingleObjectMixin, views.PermissionMixin, + TemplateView): title = _('Remove admin user') model = get_role_model() success_url = '../..' diff --git a/src/authentic2/manager/service_views.py b/src/authentic2/manager/service_views.py index ea0906e2..501f0e59 100644 --- a/src/authentic2/manager/service_views.py +++ b/src/authentic2/manager/service_views.py @@ -1,7 +1,22 @@ +# 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 . + from django.utils import six from django.utils.translation import ugettext as _ from django.contrib import messages -from django.shortcuts import get_object_or_404 from authentic2.models import Service diff --git a/src/authentic2/manager/tables.py b/src/authentic2/manager/tables.py index 189d09e2..0dc01de1 100644 --- a/src/authentic2/manager/tables.py +++ b/src/authentic2/manager/tables.py @@ -1,6 +1,21 @@ +# 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 . + +from django.contrib.auth import get_user_model from django.utils.translation import ugettext_lazy as _ -from django.utils.safestring import mark_safe -from django.contrib.auth.models import Group import django_tables2 as tables from django_tables2.utils import A @@ -9,9 +24,10 @@ from django_rbac.utils import get_role_model, get_permission_model, \ get_ou_model from authentic2.models import Service -from authentic2.compat import get_user_model from authentic2.middleware import StoreRequestMiddleware +User = get_user_model() + class PermissionLinkColumn(tables.LinkColumn): def __init__(self, viewname, **kwargs): @@ -39,7 +55,7 @@ class UserTable(tables.Table): ou = tables.Column() class Meta: - model = get_user_model() + model = User attrs = {'class': 'main', 'id': 'user-table'} fields = ('username', 'email', 'first_name', 'last_name', 'is_active', 'email_verified', 'ou') @@ -100,9 +116,14 @@ class OuUserRolesTable(tables.Table): via = tables.TemplateColumn( '''{% for rel in record.via %}{{ rel.child }} {% if not forloop.last %}, {% endif %}{% endfor %}''', verbose_name=_('Inherited from'), orderable=False) - member = tables.TemplateColumn('''{% load i18n %}''', - verbose_name=_('Member'), order_by=('member', 'via', 'name')) - + member = tables.TemplateColumn( + '{% load i18n %}', + verbose_name=_('Member'), + order_by=('member', 'via', 'name')) class Meta: models = get_role_model() @@ -117,8 +138,11 @@ class UserRolesTable(tables.Table): accessor='name', verbose_name=_('label')) ou = tables.Column() via = tables.TemplateColumn( - '''{% if not record.member %}{% for rel in record.child_relation.all %}{{ rel.child }} {% if not forloop.last %}, {% endif %}{% endfor %}{% endif %}''', - verbose_name=_('Inherited from'), orderable=False) + '{% if not record.member %}{% for rel in record.child_relation.all %}' + '{{ rel.child }} {% if not forloop.last %}, {% endif %}{% endfor %}' + '{% endif %}', + verbose_name=_('Inherited from'), + orderable=False) class Meta: models = get_role_model() diff --git a/src/authentic2/manager/urls.py b/src/authentic2/manager/urls.py index c0646d5d..57b95cb9 100644 --- a/src/authentic2/manager/urls.py +++ b/src/authentic2/manager/urls.py @@ -1,3 +1,19 @@ +# 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 . + from django.conf.urls import url from django.views.i18n import javascript_catalog @@ -127,8 +143,9 @@ urlpatterns = required( ) urlpatterns += [ - url(r'^jsi18n/$', javascript_catalog, - {'packages': ('authentic2.manager',)}, - name='a2-manager-javascript-catalog'), + url(r'^jsi18n/$', + javascript_catalog, + {'packages': ('authentic2.manager',)}, + name='a2-manager-javascript-catalog'), url(r'^select2.json$', views.select2, name='django_select2-json'), ] diff --git a/src/authentic2/manager/user_views.py b/src/authentic2/manager/user_views.py index 711b1ea6..79428d4f 100644 --- a/src/authentic2/manager/user_views.py +++ b/src/authentic2/manager/user_views.py @@ -1,11 +1,24 @@ +# 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 datetime -import uuid import collections from django.db import models from django.utils.translation import ugettext_lazy as _, ugettext -from django.utils.http import urlsafe_base64_encode -from django.utils.encoding import force_bytes from django.utils.html import format_html from django.core.mail import EmailMultiAlternatives from django.template import loader @@ -13,14 +26,9 @@ from django.core.urlresolvers import reverse from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.contrib import messages -from django.http import HttpResponseRedirect, QueryDict -from django.views.generic.detail import SingleObjectMixin -from django.views.generic import View -from import_export.fields import Field import tablib -from authentic2.constants import SWITCH_USER_SESSION_KEY from authentic2.models import Attribute, AttributeValue, PasswordReset from authentic2.utils import switch_user, send_password_reset_mail, redirect, select_next_url from authentic2.a2_rbac.utils import get_default_ou @@ -176,8 +184,7 @@ user_add = UserAddView.as_view() def user_add_default_ou(request): ou = get_default_ou() - return redirect(request, 'a2-manager-user-add', kwargs={'ou_pk': ou.id}, - keep_params=True) + return redirect(request, 'a2-manager-user-add', kwargs={'ou_pk': ou.id}, keep_params=True) class UserDetailView(OtherActionsMixin, BaseDetailView): @@ -464,12 +471,12 @@ class UserChangeEmailView(BaseEditView): response = super(UserChangeEmailView, self).form_valid(form) new_email = form.cleaned_data['new_email'] hooks.call_hooks( - 'event', - name='manager-change-email-request', - user=self.request.user, - instance=form.instance, - form=form, - email=new_email) + 'event', + name='manager-change-email-request', + user=self.request.user, + instance=form.instance, + form=form, + email=new_email) return response user_change_email = UserChangeEmailView.as_view() diff --git a/src/authentic2/manager/utils.py b/src/authentic2/manager/utils.py index 619b5e7c..bf3d00a0 100644 --- a/src/authentic2/manager/utils.py +++ b/src/authentic2/manager/utils.py @@ -1,4 +1,18 @@ - +# 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 . from django_rbac.utils import get_ou_model diff --git a/src/authentic2/manager/views.py b/src/authentic2/manager/views.py index c23d2bda..cd2873c5 100644 --- a/src/authentic2/manager/views.py +++ b/src/authentic2/manager/views.py @@ -1,3 +1,19 @@ +# 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 json import inspect @@ -25,12 +41,12 @@ from gadjo.templatetags.gadjo import xstatic from django_rbac.utils import get_ou_model from authentic2.data_transfer import export_site, import_site, DataImportError, ImportContext -from authentic2.forms import modelform_factory, SiteImportForm +from authentic2.forms.profile import modelform_factory from authentic2.utils import redirect, batch_queryset from authentic2.decorators import json as json_view from authentic2 import hooks -from . import app_settings, utils +from . import app_settings, utils, forms # https://github.com/MongoEngine/django-mongoengine/blob/master/django_mongoengine/views/edit.py @@ -643,8 +659,9 @@ class HideOUColumnMixin(object): def get_table(self, **kwargs): OU = get_ou_model() exclude_ou = False - if (hasattr(self, 'search_form') and self.search_form.is_valid() and - self.search_form.cleaned_data.get('ou') is not None): + if (hasattr(self, 'search_form') + and self.search_form.is_valid() + and self.search_form.cleaned_data.get('ou') is not None): exclude_ou = True if OU.objects.count() < 2: exclude_ou = True @@ -680,7 +697,7 @@ site_export = SiteExport.as_view() class SiteImportView(MediaMixin, FormView): - form_class = SiteImportForm + form_class = forms.SiteImportForm template_name = 'authentic2/manager/site_import.html' success_url = reverse_lazy('a2-manager-homepage') diff --git a/src/authentic2/manager/widgets.py b/src/authentic2/manager/widgets.py index b50f587c..d456e7da 100644 --- a/src/authentic2/manager/widgets.py +++ b/src/authentic2/manager/widgets.py @@ -1,3 +1,19 @@ +# 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 operator from django_select2.forms import ModelSelect2Widget, ModelSelect2MultipleWidget diff --git a/src/authentic2/managers.py b/src/authentic2/managers.py index 1303f186..31a48a59 100644 --- a/src/authentic2/managers.py +++ b/src/authentic2/managers.py @@ -1,3 +1,19 @@ +# 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 . + from datetime import timedelta import logging @@ -5,7 +21,6 @@ import logging from django.db import models from django.db.models.query import QuerySet from django.utils.timezone import now -from django.utils.http import urlquote from django.conf import settings from django.contrib.contenttypes.models import ContentType diff --git a/src/authentic2/middleware.py b/src/authentic2/middleware.py index ef526498..7dbe1c48 100644 --- a/src/authentic2/middleware.py +++ b/src/authentic2/middleware.py @@ -1,3 +1,19 @@ +# 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 logging import datetime import random @@ -18,6 +34,7 @@ from django.shortcuts import render from . import app_settings, utils, plugins + class ThreadCollector(object): def __init__(self): if threading is None: @@ -48,6 +65,7 @@ class ThreadCollector(object): MESSAGE_IF_STRING_REPRESENTATION_INVALID = '[Could not get log message]' + class ThreadTrackingHandler(logging.Handler): def __init__(self, collector): logging.Handler.__init__(self) @@ -77,6 +95,7 @@ collector = ThreadCollector() logging_handler = ThreadTrackingHandler(collector) logging.root.addHandler(logging_handler) + class LoggingCollectorMiddleware(object): def process_request(self, request): collector.clear_collection() @@ -90,6 +109,7 @@ class LoggingCollectorMiddleware(object): request.logs = collector.get_collection() request.exception = exception + class CollectIPMiddleware(object): def process_response(self, request, response): # only collect IP if session is used @@ -104,6 +124,7 @@ class CollectIPMiddleware(object): request.session.modified = True return response + class OpenedSessionCookieMiddleware(object): def process_response(self, request, response): # do not emit cookie for API requests @@ -122,6 +143,7 @@ class OpenedSessionCookieMiddleware(object): response.delete_cookie(name, domain=domain) return response + class RequestIdMiddleware(object): def process_request(self, request): if not hasattr(request, 'request_id'): @@ -136,6 +158,7 @@ class RequestIdMiddleware(object): hexlify(struct.pack('I', random_id)), encoding='ascii') + class StoreRequestMiddleware(object): collection = {} @@ -153,6 +176,7 @@ class StoreRequestMiddleware(object): def get_request(cls): return cls.collection.get(threading.currentThread()) + class ViewRestrictionMiddleware(object): RESTRICTION_SESSION_KEY = 'view-restriction' @@ -185,6 +209,7 @@ class ViewRestrictionMiddleware(object): messages.warning(request, _('You must change your password to continue')) return utils.redirect_and_come_back(request, view) + class XForwardedForMiddleware(object): '''Copy the first address from X-Forwarded-For header to the REMOTE_ADDR meta. @@ -195,6 +220,7 @@ class XForwardedForMiddleware(object): request.META['REMOTE_ADDR'] = request.META['HTTP_X_FORWARDED_FOR'].split(",")[0].strip() return None + class DisplayMessageBeforeRedirectMiddleware(object): '''Verify if messages are currently stored and if there is a redirection to another domain, in this case show an intermediate page. @@ -236,7 +262,6 @@ class DisplayMessageBeforeRedirectMiddleware(object): class ServiceAccessControlMiddleware(object): - def process_exception(self, request, exception): if not isinstance(exception, (utils.ServiceAccessDenied,)): return None diff --git a/src/authentic2/models.py b/src/authentic2/models.py index dcf0a911..d22e3a6b 100644 --- a/src/authentic2/models.py +++ b/src/authentic2/models.py @@ -1,3 +1,19 @@ +# 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 time import uuid from django.utils.http import urlquote @@ -7,24 +23,22 @@ from django.db.models.query import Q from django.utils import six from django.utils.translation import ugettext_lazy as _ from django.utils.six.moves.urllib import parse as urlparse -from django.core.exceptions import ValidationError, FieldDoesNotExist +from django.core.exceptions import ValidationError from django.contrib.contenttypes.models import ContentType from model_utils.managers import QueryManager from authentic2.a2_rbac.models import Role -from authentic2.a2_rbac.utils import get_default_ou from django_rbac.utils import get_role_model_name try: from django.contrib.contenttypes.fields import GenericForeignKey except ImportError: from django.contrib.contenttypes.generic import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from . import managers # install our natural_key implementation -from . import natural_key +from . import natural_key as unused_natural_key # noqa: F401 from .utils import ServiceAccessDenied @@ -33,31 +47,24 @@ class DeletedUser(models.Model): objects = managers.DeletedUserManager() - user = models.ForeignKey(settings.AUTH_USER_MODEL, - verbose_name=_('user')) - creation = models.DateTimeField(auto_now_add=True, - verbose_name=_('creation date')) + user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_('user')) + creation = models.DateTimeField(auto_now_add=True, verbose_name=_('creation date')) class Meta: verbose_name = _('user to delete') verbose_name_plural = _('users to delete') + @six.python_2_unicode_compatible class UserExternalId(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, - verbose_name=_('user')) - source = models.CharField(max_length=256, - verbose_name=_('source')) - external_id = models.CharField(max_length=256, - verbose_name=_('external id')) - created = models.DateTimeField(auto_now_add=True, - verbose_name=_('creation date')) - updated = models.DateTimeField(auto_now=True, - verbose_name=_('last update date')) + user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_('user')) + source = models.CharField(max_length=256, verbose_name=_('source')) + external_id = models.CharField(max_length=256, verbose_name=_('external id')) + created = models.DateTimeField(auto_now_add=True, verbose_name=_('creation date')) + updated = models.DateTimeField(auto_now=True, verbose_name=_('last update date')) def __str__(self): - return u'{0} is {1} on {2}'.format( - self.user, self.external_id, self.source) + return u'{0} is {1} on {2}'.format(self.user, self.external_id, self.source) def __repr__(self): return '. + from django.db import models from django.contrib.contenttypes.models import ContentType diff --git a/src/authentic2/nonce/__init__.py b/src/authentic2/nonce/__init__.py index 51c91cd7..379d7346 100644 --- a/src/authentic2/nonce/__init__.py +++ b/src/authentic2/nonce/__init__.py @@ -1,3 +1,19 @@ +# 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 . + from authentic2.nonce.utils import accept_nonce, cleanup_nonces __all__ = ('accept_nonce', 'cleanup_nonces') diff --git a/src/authentic2/nonce/models.py b/src/authentic2/nonce/models.py index e9b6d866..b253d0c8 100644 --- a/src/authentic2/nonce/models.py +++ b/src/authentic2/nonce/models.py @@ -1,4 +1,18 @@ -import datetime as dt +# 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 . from django.db import models from django.utils import timezone, six @@ -7,19 +21,20 @@ __all__ = ('Nonce',) _NONCE_LENGTH_CONSTANT = 256 + class NonceManager(models.Manager): def cleanup(self, now=None): now = now or timezone.now() self.filter(not_on_or_after__lt=now).delete() + @six.python_2_unicode_compatible class Nonce(models.Model): value = models.CharField(max_length=_NONCE_LENGTH_CONSTANT) - context = models.CharField(max_length=_NONCE_LENGTH_CONSTANT, blank=True, - null=True) + context = models.CharField(max_length=_NONCE_LENGTH_CONSTANT, blank=True, null=True) not_on_or_after = models.DateTimeField(blank=True, null=True) - objects = NonceManager() + objects = NonceManager() def __str__(self): return self.value diff --git a/src/authentic2/nonce/utils.py b/src/authentic2/nonce/utils.py index 14f2003b..c20b7d92 100644 --- a/src/authentic2/nonce/utils.py +++ b/src/authentic2/nonce/utils.py @@ -1,3 +1,19 @@ +# 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 os.path import datetime as dt from calendar import timegm @@ -12,14 +28,15 @@ __all__ = ('accept_nonce', 'cleanup_nonces') STORAGE_MODEL = 'model' STORAGE_FILESYSTEM = 'fs:' + def compute_not_on_or_after(now, not_on_or_after): - try: # first try integer semantic + try: # first try integer semantic seconds = int(not_on_or_after) not_on_or_after = now + dt.timedelta(seconds=seconds) except ValueError: - try: # try timedelta semantic + try: # try timedelta semantic not_on_or_after = now + not_on_or_after - except TypeError: # datetime semantic + except TypeError: # datetime semantic pass return not_on_or_after @@ -28,6 +45,7 @@ def compute_not_on_or_after(now, not_on_or_after): # condition errors. But any other OSError is problematic and should be # reported to the administrator by mail and so we let it unroll the stack + def unlink_if_exists(path): try: os.unlink(path) @@ -35,8 +53,8 @@ def unlink_if_exists(path): if e.errno != errno.ENOENT: raise -def accept_nonce_file_storage(path, now, value, context=None, - not_on_or_after=None): + +def accept_nonce_file_storage(path, now, value, context=None, not_on_or_after=None): ''' Use a directory as a storage for nonce-context values. The last modification time is used to store the expiration timestamp. @@ -81,13 +99,13 @@ def accept_nonce_file_storage(path, now, value, context=None, return False return True + def accept_nonce_model(now, value, context=None, not_on_or_after=None): import models if not_on_or_after: not_on_or_after = compute_not_on_or_after(now, not_on_or_after) - nonce, created = models.Nonce.objects.get_or_create(value=value, - context=context) + nonce, created = models.Nonce.objects.get_or_create(value=value, context=context) if created or (nonce.not_on_or_after and nonce.not_on_or_after < now): nonce.not_on_or_after = not_on_or_after nonce.save() @@ -95,6 +113,7 @@ def accept_nonce_model(now, value, context=None, not_on_or_after=None): else: return False + def cleanup_nonces_file_storage(dir_path, now): for nonce_path in glob.iglob(os.path.join(dir_path, '*')): now_time = timegm(now.utctimetuple()) @@ -112,6 +131,7 @@ def cleanup_nonces_file_storage(dir_path, now): continue raise + def cleanup_nonces(now=None): ''' Cleanup stored nonce whose timestamp has expired, i.e. @@ -135,6 +155,7 @@ def cleanup_nonces(now=None): else: raise ValueError('Invalid NONCE_STORAGE setting: %r' % mode) + def accept_nonce(value, context=None, not_on_or_after=None, now=None): ''' Verify that the given nonce value has not already been seen in the @@ -167,11 +188,9 @@ def accept_nonce(value, context=None, not_on_or_after=None, now=None): now = now or dt.datetime.now() mode = getattr(settings, 'NONCE_STORAGE', STORAGE_MODEL) if mode == STORAGE_MODEL: - return accept_nonce_model(now, value, context=context, - not_on_or_after=not_on_or_after) + return accept_nonce_model(now, value, context=context, not_on_or_after=not_on_or_after) elif mode.startswith(STORAGE_FILESYSTEM): dir_path = mode[len(STORAGE_FILESYSTEM):] - return accept_nonce_file_storage(dir_path, now, value, - context=context, not_on_or_after=not_on_or_after) + return accept_nonce_file_storage(dir_path, now, value, context=context, not_on_or_after=not_on_or_after) else: raise ValueError('Invalid NONCE_STORAGE setting: %r' % mode) diff --git a/src/authentic2/passwords.py b/src/authentic2/passwords.py index 361a2b9d..d58aab64 100644 --- a/src/authentic2/passwords.py +++ b/src/authentic2/passwords.py @@ -1,3 +1,19 @@ +# 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 string import random import re @@ -6,10 +22,10 @@ import abc from django.utils.translation import ugettext as _ from django.utils.module_loading import import_string from django.utils.functional import lazy -from django.utils.safestring import mark_safe from django.utils import six from django.core.exceptions import ValidationError + from . import app_settings diff --git a/src/authentic2/plugins.py b/src/authentic2/plugins.py index 8ac25ede..2692e14f 100644 --- a/src/authentic2/plugins.py +++ b/src/authentic2/plugins.py @@ -1,3 +1,19 @@ +# 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 . + """ Use setuptools entrypoints to find plugins @@ -17,11 +33,13 @@ __ALL__ = ['get_plugins'] PLUGIN_CACHE = {} + class PluginError(Exception): pass DEFAULT_GROUP_NAME = 'authentic2.plugin' + def get_plugins(group_name=DEFAULT_GROUP_NAME, use_cache=True, *args, **kwargs): '''Traverse all entry points for group_name and instantiate them using args and kwargs. @@ -40,8 +58,8 @@ def get_plugins(group_name=DEFAULT_GROUP_NAME, use_cache=True, *args, **kwargs): PLUGIN_CACHE[group_name] = plugins return plugins -def register_plugins_urls(urlpatterns, - group_name=DEFAULT_GROUP_NAME): + +def register_plugins_urls(urlpatterns, group_name=DEFAULT_GROUP_NAME): '''Call get_before_urls and get_after_urls on all plugins providing them and add those urls to the given urlpatterns. @@ -62,6 +80,7 @@ def register_plugins_urls(urlpatterns, return before_urls + urlpatterns + after_urls + def register_plugins_installed_apps(installed_apps, group_name=DEFAULT_GROUP_NAME): '''Call get_apps() on all plugins of group_name and add the returned applications path to the installed_apps sequence. @@ -77,8 +96,8 @@ def register_plugins_installed_apps(installed_apps, group_name=DEFAULT_GROUP_NAM installed_apps.append(app) return installed_apps -def register_plugins_middleware(middleware_classes, - group_name=DEFAULT_GROUP_NAME): + +def register_plugins_middleware(middleware_classes, group_name=DEFAULT_GROUP_NAME): middleware_classes = list(middleware_classes) for plugin in get_plugins(group_name): if hasattr(plugin, 'get_before_middleware'): @@ -93,8 +112,8 @@ def register_plugins_middleware(middleware_classes, middleware_classes.append(app) return tuple(middleware_classes) -def register_plugins_authentication_backends(authentication_backends, - group_name=DEFAULT_GROUP_NAME): + +def register_plugins_authentication_backends(authentication_backends, group_name=DEFAULT_GROUP_NAME): authentication_backends = list(authentication_backends) for plugin in get_plugins(group_name): if hasattr(plugin, 'get_authentication_backends'): @@ -104,8 +123,8 @@ def register_plugins_authentication_backends(authentication_backends, authentication_backends.append(cls) return tuple(authentication_backends) -def register_plugins_authenticators(authenticators=(), - group_name=DEFAULT_GROUP_NAME): + +def register_plugins_authenticators(authenticators=(), group_name=DEFAULT_GROUP_NAME): authenticators = list(authenticators) for plugin in get_plugins(group_name): if hasattr(plugin, 'get_authenticators'): @@ -115,8 +134,8 @@ def register_plugins_authenticators(authenticators=(), authenticators.append(cls) return tuple(authenticators) -def register_plugins_idp_backends(idp_backends, - group_name=DEFAULT_GROUP_NAME): + +def register_plugins_idp_backends(idp_backends, group_name=DEFAULT_GROUP_NAME): idp_backends = list(idp_backends) for plugin in get_plugins(group_name): if hasattr(plugin, 'get_idp_backends'): diff --git a/src/authentic2/profile_forms.py b/src/authentic2/profile_forms.py deleted file mode 100644 index 540591d5..00000000 --- a/src/authentic2/profile_forms.py +++ /dev/null @@ -1,38 +0,0 @@ -import logging - -from django import forms -from django.utils.translation import ugettext as _ -from django.contrib.auth import get_user_model - -from .backends import get_user_queryset -from .utils import send_password_reset_mail -from . import hooks, app_settings - - -logger = logging.getLogger(__name__) - - -class PasswordResetForm(forms.Form): - next_url = forms.CharField(widget=forms.HiddenInput, required=False) - - email = forms.EmailField( - label=_("Email"), max_length=254) - - def save(self): - """ - Generates a one-use only link for resetting password and sends to the - user. - """ - email = self.cleaned_data["email"].strip() - users = get_user_queryset() - active_users = users.filter(email__iexact=email, is_active=True) - for user in active_users: - # we don't set the password to a random string, as some users should not have - # a password - set_random_password = (user.has_usable_password() - and app_settings.A2_SET_RANDOM_PASSWORD_ON_RESET) - send_password_reset_mail(user, set_random_password=set_random_password, - next_url=self.cleaned_data.get('next_url')) - if not active_users: - logger.info(u'password reset requests for "%s", no user found') - hooks.call_hooks('event', name='password-reset', email=email, users=active_users) diff --git a/src/authentic2/profile_urls.py b/src/authentic2/profile_urls.py deleted file mode 100644 index f27ab78b..00000000 --- a/src/authentic2/profile_urls.py +++ /dev/null @@ -1,97 +0,0 @@ -from django.conf.urls import url -from django.contrib.auth import views as auth_views, REDIRECT_FIELD_NAME -from django.contrib.auth.decorators import login_required -from django.core.urlresolvers import reverse -from django.http import HttpResponseRedirect -from django.contrib import messages -from django.utils.translation import ugettext as _ -from django.views.decorators.debug import sensitive_post_parameters - -from authentic2.utils import import_module_or_class, redirect, user_can_change_password -from . import app_settings, decorators, profile_views, hooks -from .views import (logged_in, edit_profile, email_change, email_change_verify, profile) - -SET_PASSWORD_FORM_CLASS = import_module_or_class( - app_settings.A2_REGISTRATION_SET_PASSWORD_FORM_CLASS) -CHANGE_PASSWORD_FORM_CLASS = import_module_or_class( - app_settings.A2_REGISTRATION_CHANGE_PASSWORD_FORM_CLASS) - -@sensitive_post_parameters() -@login_required -@decorators.setting_enabled('A2_REGISTRATION_CAN_CHANGE_PASSWORD') -def password_change_view(request, *args, **kwargs): - post_change_redirect = kwargs.pop('post_change_redirect', None) - if 'next_url' in request.POST and request.POST['next_url']: - post_change_redirect = request.POST['next_url'] - elif REDIRECT_FIELD_NAME in request.GET: - post_change_redirect = request.GET[REDIRECT_FIELD_NAME] - elif post_change_redirect is None: - post_change_redirect = reverse('account_management') - if not user_can_change_password(request=request): - messages.warning(request, _('Password change is forbidden')) - return redirect(request, post_change_redirect) - if 'cancel' in request.POST: - return redirect(request, post_change_redirect) - kwargs['post_change_redirect'] = post_change_redirect - extra_context = kwargs.setdefault('extra_context', {}) - extra_context['view'] = password_change_view - extra_context[REDIRECT_FIELD_NAME] = post_change_redirect - if not request.user.has_usable_password(): - kwargs['password_change_form'] = SET_PASSWORD_FORM_CLASS - response = auth_views.password_change(request, *args, **kwargs) - if isinstance(response, HttpResponseRedirect): - hooks.call_hooks('event', name='change-password', user=request.user, request=request) - messages.info(request, _('Password changed')) - return response - -password_change_view.title = _('Password Change') -password_change_view.do_not_call_in_templates = True - - -urlpatterns = [ - url(r'^logged-in/$', logged_in, name='logged-in'), - url(r'^edit/$', edit_profile, name='profile_edit'), - url(r'^edit/(?P[-\w]+)/$', edit_profile, name='profile_edit_with_scope'), - url(r'^change-email/$', email_change, name='email-change'), - url(r'^change-email/verify/$', email_change_verify, - name='email-change-verify'), - url(r'^$', profile, name='account_management'), - url(r'^password/change/$', - password_change_view, - {'password_change_form': CHANGE_PASSWORD_FORM_CLASS}, - name='password_change'), - url(r'^password/change/done/$', - auth_views.password_change_done, - name='password_change_done'), - - # Password reset - url(r'^password/reset/confirm/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', - profile_views.password_reset_confirm, - name='password_reset_confirm'), - url(r'^password/reset/$', - profile_views.password_reset, - name='password_reset'), - - # Legacy - url(r'^password/change/$', - password_change_view, - {'password_change_form': CHANGE_PASSWORD_FORM_CLASS}, - name='auth_password_change'), - url(r'^password/change/done/$', - auth_views.password_change_done, - name='auth_password_change_done'), - url(r'^password/reset/confirm/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', - auth_views.password_reset_confirm, - {'set_password_form': SET_PASSWORD_FORM_CLASS}, - name='auth_password_reset_confirm'), - url(r'^password/reset/$', - auth_views.password_reset, - name='auth_password_reset'), - url(r'^password/reset/complete/$', - auth_views.password_reset_complete, - name='auth_password_reset_complete'), - url(r'^password/reset/done/$', - auth_views.password_reset_done, - name='auth_password_reset_done'), - url(r'^switch-back/$', profile_views.switch_back, name='a2-switch-back'), -] diff --git a/src/authentic2/profile_views.py b/src/authentic2/profile_views.py deleted file mode 100644 index 8739ced9..00000000 --- a/src/authentic2/profile_views.py +++ /dev/null @@ -1,127 +0,0 @@ -import logging - -from django.views.generic import FormView -from django.contrib import messages -from django.contrib.auth import get_user_model, REDIRECT_FIELD_NAME, authenticate -from django.http import Http404 -from django.utils.translation import ugettext as _ -from django.utils.http import urlsafe_base64_decode - -from .compat import default_token_generator -from .registration_backend.forms import SetPasswordForm -from . import app_settings, cbv, profile_forms, utils, hooks - - -class PasswordResetView(cbv.NextURLViewMixin, FormView): - '''Ask for an email and send a password reset link by mail''' - form_class = profile_forms.PasswordResetForm - title = _('Password Reset') - - def get_template_names(self): - return [ - 'authentic2/password_reset_form.html', - 'registration/password_reset_form.html', - ] - - def get_form_kwargs(self, **kwargs): - kwargs = super(PasswordResetView, self).get_form_kwargs(**kwargs) - initial = kwargs.setdefault('initial', {}) - initial['next_url'] = self.request.GET.get(REDIRECT_FIELD_NAME, '') - return kwargs - - def get_context_data(self, **kwargs): - ctx = super(PasswordResetView, self).get_context_data(**kwargs) - if app_settings.A2_USER_CAN_RESET_PASSWORD is False: - raise Http404('Password reset is not allowed.') - ctx['title'] = _('Password reset') - return ctx - - def form_valid(self, form): - form.save() - # return to next URL - messages.info(self.request, _('If your email address exists in our ' - 'database, you will receive an email ' - 'containing instructions to reset ' - 'your password')) - return super(PasswordResetView, self).form_valid(form) - -password_reset = PasswordResetView.as_view() - - -class PasswordResetConfirmView(cbv.RedirectToNextURLViewMixin, FormView): - '''Validate password reset link, show a set password form and login - the user. - ''' - form_class = SetPasswordForm - title = _('Password Reset') - - def get_template_names(self): - return [ - 'registration/password_reset_confirm.html', - 'authentic2/password_reset_confirm.html', - ] - - def dispatch(self, request, *args, **kwargs): - validlink = True - uidb64 = kwargs['uidb64'] - self.token = token = kwargs['token'] - - UserModel = get_user_model() - # checked by URLconf - assert uidb64 is not None and token is not None - try: - uid = urlsafe_base64_decode(uidb64) - # use authenticate to eventually get an LDAPUser - self.user = authenticate(user=UserModel._default_manager.get(pk=uid)) - except (TypeError, ValueError, OverflowError, - UserModel.DoesNotExist): - validlink = False - messages.warning(request, _('User not found')) - - if validlink and not default_token_generator.check_token(self.user, token): - validlink = False - messages.warning(request, _('You reset password link is invalid ' - 'or has expired')) - if not validlink: - return utils.redirect(request, self.get_success_url()) - can_reset_password = utils.get_user_flag(user=self.user, - name='can_reset_password', - default=self.user.has_usable_password()) - if not can_reset_password: - messages.warning(request, _('It\'s not possible to reset your password. Please ' - 'contact an administrator.')) - return utils.redirect(request, self.get_success_url()) - return super(PasswordResetConfirmView, self).dispatch(request, *args, - **kwargs) - - def get_context_data(self, **kwargs): - ctx = super(PasswordResetConfirmView, self).get_context_data(**kwargs) - # compatibility with existing templates ! - ctx['title'] = _('Enter new password') - ctx['validlink'] = True - return ctx - - def get_form_kwargs(self): - kwargs = super(PasswordResetConfirmView, self).get_form_kwargs() - kwargs['user'] = self.user - return kwargs - - def form_valid(self, form): - # Changing password by mail validate the email - form.user.email_verified = True - form.save() - hooks.call_hooks('event', name='password-reset-confirm', user=form.user, token=self.token, - form=form) - logging.getLogger(__name__).info(u'user %s resetted its password with ' - 'token %r...', self.user, - self.token[:9]) - return self.finish() - - def finish(self): - return utils.simulate_authentication(self.request, self.user, 'email') - -password_reset_confirm = PasswordResetConfirmView.as_view() - - -def switch_back(request): - return utils.switch_back(request) diff --git a/src/authentic2/registration_backend/__init__.py b/src/authentic2/registration_backend/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2/registration_backend/urls.py b/src/authentic2/registration_backend/urls.py deleted file mode 100644 index 264883c7..00000000 --- a/src/authentic2/registration_backend/urls.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.conf.urls import url - -from django.views.generic.base import TemplateView -from django.contrib.auth.decorators import login_required - -from .views import RegistrationView, registration_completion, DeleteView, registration_complete - -urlpatterns = [ - url(r'^activate/(?P[\w: -]+)/$', - registration_completion, name='registration_activate'), - url(r'^register/$', - RegistrationView.as_view(), - name='registration_register'), - url(r'^register/complete/$', - registration_complete, - name='registration_complete'), - url(r'^register/closed/$', - TemplateView.as_view(template_name='registration/registration_closed.html'), - name='registration_disallowed'), - url(r'^delete/$', - login_required(DeleteView.as_view()), - name='delete_account'), -] diff --git a/src/authentic2/registration_backend/views.py b/src/authentic2/registration_backend/views.py deleted file mode 100644 index 2d3e73a5..00000000 --- a/src/authentic2/registration_backend/views.py +++ /dev/null @@ -1,416 +0,0 @@ -import collections -import logging -import random - -from django.conf import settings -from django.shortcuts import get_object_or_404 -from django.utils.translation import ugettext as _ -from django.utils.http import urlquote -from django.contrib import messages -from django.contrib.auth import REDIRECT_FIELD_NAME -from django.core import signing -from django.views.generic.base import TemplateView -from django.views.generic.edit import FormView, CreateView -from django.contrib.auth import get_user_model -from django.forms import CharField, Form -from django.core.urlresolvers import reverse_lazy -from django.http import Http404, HttpResponseBadRequest - -from authentic2.utils import (import_module_or_class, redirect, make_url, get_fields_and_labels, - simulate_authentication) -from authentic2.a2_rbac.utils import get_default_ou -from authentic2 import hooks - -from django_rbac.utils import get_ou_model - -from .. import models, app_settings, compat, cbv, forms, validators, utils, constants -from .forms import RegistrationCompletionForm, DeleteAccountForm -from .forms import RegistrationCompletionFormNoPassword -from authentic2.a2_rbac.models import OrganizationalUnit - -logger = logging.getLogger(__name__) - -User = compat.get_user_model() - - -def valid_token(method): - def f(request, *args, **kwargs): - try: - request.token = signing.loads(kwargs['registration_token'].replace(' ', ''), - max_age=settings.ACCOUNT_ACTIVATION_DAYS * 3600 * 24) - except signing.SignatureExpired: - messages.warning(request, _('Your activation key is expired')) - return redirect(request, 'registration_register') - except signing.BadSignature: - messages.warning(request, _('Activation failed')) - return redirect(request, 'registration_register') - return method(request, *args, **kwargs) - return f - - -class BaseRegistrationView(FormView): - form_class = import_module_or_class(app_settings.A2_REGISTRATION_FORM_CLASS) - template_name = 'registration/registration_form.html' - title = _('Registration') - - def dispatch(self, request, *args, **kwargs): - if not getattr(settings, 'REGISTRATION_OPEN', True): - raise Http404('Registration is not open.') - self.token = {} - self.ou = get_default_ou() - # load pre-filled values - if request.GET.get('token'): - try: - self.token = signing.loads( - request.GET.get('token'), - max_age=settings.ACCOUNT_ACTIVATION_DAYS * 3600 * 24) - except (TypeError, ValueError, signing.BadSignature) as e: - logger.warning(u'registration_view: invalid token: %s', e) - return HttpResponseBadRequest('invalid token', content_type='text/plain') - if 'ou' in self.token: - self.ou = OrganizationalUnit.objects.get(pk=self.token['ou']) - self.next_url = self.token.pop(REDIRECT_FIELD_NAME, utils.select_next_url(request, None)) - return super(BaseRegistrationView, self).dispatch(request, *args, **kwargs) - - def form_valid(self, form): - email = form.cleaned_data.pop('email') - for field in form.cleaned_data: - self.token[field] = form.cleaned_data[field] - - # propagate service to the registration completion view - if constants.SERVICE_FIELD_NAME in self.request.GET: - self.token[constants.SERVICE_FIELD_NAME] = \ - self.request.GET[constants.SERVICE_FIELD_NAME] - - self.token.pop(REDIRECT_FIELD_NAME, None) - self.token.pop('email', None) - - utils.send_registration_mail(self.request, email, next_url=self.next_url, - ou=self.ou, **self.token) - self.request.session['registered_email'] = email - return redirect(self.request, 'registration_complete', params={REDIRECT_FIELD_NAME: self.next_url}) - - def get_context_data(self, **kwargs): - context = super(BaseRegistrationView, self).get_context_data(**kwargs) - parameters = {'request': self.request, - 'context': context} - blocks = [utils.get_authenticator_method(authenticator, 'registration', parameters) - for authenticator in utils.get_backends('AUTH_FRONTENDS')] - context['frontends'] = collections.OrderedDict((block['id'], block) - for block in blocks if block) - return context - - -class RegistrationView(cbv.ValidateCSRFMixin, BaseRegistrationView): - pass - - -class RegistrationCompletionView(CreateView): - model = get_user_model() - success_url = 'auth_homepage' - - def get_template_names(self): - if self.users and not 'create' in self.request.GET: - return ['registration/registration_completion_choose.html'] - else: - return ['registration/registration_completion_form.html'] - - def get_success_url(self): - try: - redirect_url, next_field = app_settings.A2_REGISTRATION_REDIRECT - except Exception: - redirect_url = app_settings.A2_REGISTRATION_REDIRECT - next_field = REDIRECT_FIELD_NAME - - if self.token and self.token.get(REDIRECT_FIELD_NAME): - url = self.token[REDIRECT_FIELD_NAME] - if redirect_url: - url = make_url(redirect_url, params={next_field: url}) - else: - if redirect_url: - url = redirect_url - else: - url = make_url(self.success_url) - return url - - def dispatch(self, request, *args, **kwargs): - self.token = request.token - self.authentication_method = self.token.get('authentication_method', 'email') - self.email = request.token['email'] - if 'ou' in self.token: - self.ou = OrganizationalUnit.objects.get(pk=self.token['ou']) - else: - self.ou = get_default_ou() - self.users = User.objects.filter(email__iexact=self.email) \ - .order_by('date_joined') - if self.ou: - self.users = self.users.filter(ou=self.ou) - self.email_is_unique = app_settings.A2_EMAIL_IS_UNIQUE \ - or app_settings.A2_REGISTRATION_EMAIL_IS_UNIQUE - if self.ou: - self.email_is_unique |= self.ou.email_is_unique - self.init_fields_labels_and_help_texts() - # if registration is done during an SSO add the service to the registration event - self.service = self.token.get(constants.SERVICE_FIELD_NAME) - return super(RegistrationCompletionView, self) \ - .dispatch(request, *args, **kwargs) - - def init_fields_labels_and_help_texts(self): - attributes = models.Attribute.objects.filter( - asked_on_registration=True) - default_fields = attributes.values_list('name', flat=True) - required_fields = models.Attribute.objects.filter(required=True) \ - .values_list('name', flat=True) - fields, labels = get_fields_and_labels( - app_settings.A2_REGISTRATION_FIELDS, - default_fields, - app_settings.A2_REGISTRATION_REQUIRED_FIELDS, - app_settings.A2_REQUIRED_FIELDS, - models.Attribute.objects.filter(required=True).values_list('name', flat=True)) - help_texts = {} - if app_settings.A2_REGISTRATION_FORM_USERNAME_LABEL: - labels['username'] = app_settings.A2_REGISTRATION_FORM_USERNAME_LABEL - if app_settings.A2_REGISTRATION_FORM_USERNAME_HELP_TEXT: - help_texts['username'] = \ - app_settings.A2_REGISTRATION_FORM_USERNAME_HELP_TEXT - required = list(app_settings.A2_REGISTRATION_REQUIRED_FIELDS) + \ - list(required_fields) - if 'email' in fields: - fields.remove('email') - for field in self.token.get('skip_fields') or []: - if field in fields: - fields.remove(field) - self.fields = fields - self.labels = labels - self.required = required - self.help_texts = help_texts - - def get_form_class(self): - if not self.token.get('valid_email', True): - self.fields.append('email') - self.required.append('email') - form_class = RegistrationCompletionForm - if self.token.get('no_password', False): - form_class = RegistrationCompletionFormNoPassword - form_class = forms.modelform_factory(self.model, - form=form_class, - fields=self.fields, - labels=self.labels, - required=self.required, - help_texts=self.help_texts) - if 'username' in self.fields and app_settings.A2_REGISTRATION_FORM_USERNAME_REGEX: - # Keep existing field label and help_text - old_field = form_class.base_fields['username'] - field = CharField( - max_length=256, - label=old_field.label, - help_text=old_field.help_text, - validators=[validators.UsernameValidator()]) - form_class = type('RegistrationForm', (form_class,), {'username': field}) - return form_class - - def get_form_kwargs(self, **kwargs): - '''Initialize mail from token''' - kwargs = super(RegistrationCompletionView, self).get_form_kwargs(**kwargs) - if 'ou' in self.token: - OU = get_ou_model() - ou = get_object_or_404(OU, id=self.token['ou']) - else: - ou = get_default_ou() - - attributes = {'email': self.email, 'ou': ou} - for key in self.token: - if key in app_settings.A2_PRE_REGISTRATION_FIELDS: - attributes[key] = self.token[key] - logger.debug(u'attributes %s', attributes) - - prefilling_list = utils.accumulate_from_backends(self.request, 'registration_form_prefill') - logger.debug(u'prefilling_list %s', prefilling_list) - # Build a single meaningful prefilling with sets of values - prefilling = {} - for p in prefilling_list: - for name, values in p.items(): - if name in self.fields: - prefilling.setdefault(name, set()).update(values) - logger.debug(u'prefilling %s', prefilling) - - for name, values in prefilling.items(): - attributes[name] = ' '.join(values) - logger.debug(u'attributes with prefilling %s', attributes) - - if self.token.get('user_id'): - kwargs['instance'] = User.objects.get(id=self.token.get('user_id')) - else: - init_kwargs = {} - for key in ('email', 'first_name', 'last_name', 'ou'): - if key in attributes: - init_kwargs[key] = attributes[key] - kwargs['instance'] = get_user_model()(**init_kwargs) - - return kwargs - - def get_form(self, form_class=None): - form = super(RegistrationCompletionView, self).get_form(form_class=form_class) - hooks.call_hooks('front_modify_form', self, form) - return form - - def get_context_data(self, **kwargs): - ctx = super(RegistrationCompletionView, self).get_context_data(**kwargs) - ctx['token'] = self.token - ctx['users'] = self.users - ctx['email'] = self.email - ctx['email_is_unique'] = self.email_is_unique - ctx['create'] = 'create' in self.request.GET - return ctx - - def get(self, request, *args, **kwargs): - if len(self.users) == 1 and self.email_is_unique: - # Found one user, EMAIL is unique, log her in - simulate_authentication(request, self.users[0], - method=self.authentication_method, - service_slug=self.service) - return redirect(request, self.get_success_url()) - confirm_data = self.token.get('confirm_data', False) - - if confirm_data == 'required': - fields_to_confirm = self.required - else: - fields_to_confirm = self.fields - if (all(field in self.token for field in fields_to_confirm) - and (not confirm_data or confirm_data == 'required')): - # We already have every fields - form_kwargs = self.get_form_kwargs() - form_class = self.get_form_class() - data = self.token - if 'password' in data: - data['password1'] = data['password'] - data['password2'] = data['password'] - del data['password'] - form_kwargs['data'] = data - form = form_class(**form_kwargs) - if form.is_valid(): - user = form.save() - return self.registration_success(request, user, form) - self.get_form = lambda *args, **kwargs: form - return super(RegistrationCompletionView, self).get(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - if self.users and self.email_is_unique: - # email is unique, users already exist, creating a new one is forbidden ! - return redirect(request, request.resolver_match.view_name, args=self.args, - kwargs=self.kwargs) - if 'uid' in request.POST: - uid = request.POST['uid'] - for user in self.users: - if str(user.id) == uid: - simulate_authentication(request, user, - method=self.authentication_method, - service_slug=self.service) - return redirect(request, self.get_success_url()) - return super(RegistrationCompletionView, self).post(request, *args, **kwargs) - - def form_valid(self, form): - - # remove verified fields from form, this allows an authentication - # method to provide verified data fields and to present it to the user, - # while preventing the user to modify them. - for av in models.AttributeValue.objects.with_owner(form.instance): - if av.verified and av.attribute.name in form.fields: - del form.fields[av.attribute.name] - - if ('email' in self.request.POST - and (not 'email' in self.token or self.request.POST['email'] != self.token['email']) - and not self.token.get('skip_email_check')): - # If an email is submitted it must be validated or be the same as in the token - data = form.cleaned_data - data['no_password'] = self.token.get('no_password', False) - utils.send_registration_mail( - self.request, - ou=self.ou, - next_url=self.get_success_url(), - **data) - self.request.session['registered_email'] = form.cleaned_data['email'] - return redirect(self.request, 'registration_complete') - super(RegistrationCompletionView, self).form_valid(form) - return self.registration_success(self.request, form.instance, form) - - def registration_success(self, request, user, form): - hooks.call_hooks('event', name='registration', user=user, form=form, view=self, - authentication_method=self.authentication_method, - token=request.token, service=self.service) - simulate_authentication(request, user, method=self.authentication_method, - service_slug=self.service) - messages.info(self.request, _('You have just created an account.')) - self.send_registration_success_email(user) - return redirect(request, self.get_success_url()) - - def send_registration_success_email(self, user): - if not user.email: - return - - template_names = [ - 'authentic2/registration_success' - ] - login_url = self.request.build_absolute_uri(settings.LOGIN_URL) - utils.send_templated_mail(user, template_names=template_names, - context={ - 'user': user, - 'email': user.email, - 'site': self.request.get_host(), - 'login_url': login_url, - }, - request=self.request) - - -class DeleteView(FormView): - template_name = 'authentic2/accounts_delete.html' - success_url = reverse_lazy('auth_logout') - title = _('Delete account') - - def dispatch(self, request, *args, **kwargs): - if not app_settings.A2_REGISTRATION_CAN_DELETE_ACCOUNT: - return redirect(request, '..') - return super(DeleteView, self).dispatch(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - if 'cancel' in request.POST: - return redirect(request, 'account_management') - return super(DeleteView, self).post(request, *args, **kwargs) - - def get_form_class(self): - if self.request.user.has_usable_password(): - return DeleteAccountForm - return Form - - def get_form_kwargs(self, **kwargs): - kwargs = super(DeleteView, self).get_form_kwargs(**kwargs) - if self.request.user.has_usable_password(): - kwargs['user'] = self.request.user - return kwargs - - def form_valid(self, form): - utils.send_account_deletion_mail(self.request, self.request.user) - models.DeletedUser.objects.delete_user(self.request.user) - self.request.user.email += '#%d' % random.randint(1, 10000000) - self.request.user.email_verified = False - self.request.user.save(update_fields=['email', 'email_verified']) - logger.info(u'deletion of account %s requested', self.request.user) - hooks.call_hooks('event', name='delete-account', user=self.request.user) - messages.info(self.request, - _('Your account has been scheduled for deletion. You cannot use it anymore.')) - return super(DeleteView, self).form_valid(form) - -registration_completion = valid_token(RegistrationCompletionView.as_view()) - - -class RegistrationCompleteView(TemplateView): - template_name = 'registration/registration_complete.html' - - def get_context_data(self, **kwargs): - kwargs['next_url'] = utils.select_next_url(self.request, settings.LOGIN_REDIRECT_URL) - return super(RegistrationCompleteView, self).get_context_data( - account_activation_days=settings.ACCOUNT_ACTIVATION_DAYS, - **kwargs) - - -registration_complete = RegistrationCompleteView.as_view() diff --git a/src/authentic2/saml/__init__.py b/src/authentic2/saml/__init__.py index edb7579c..01e41b30 100644 --- a/src/authentic2/saml/__init__.py +++ b/src/authentic2/saml/__init__.py @@ -1,3 +1,19 @@ +# 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 . + from django.apps import AppConfig default_app_config = 'authentic2.saml.A2SAMLAppConfig' diff --git a/src/authentic2/saml/admin.py b/src/authentic2/saml/admin.py index 8568f3c4..87c69a80 100644 --- a/src/authentic2/saml/admin.py +++ b/src/authentic2/saml/admin.py @@ -1,3 +1,19 @@ +# 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 logging from django.contrib import admin @@ -18,20 +34,20 @@ from authentic2.saml.models import (LibertyProvider, LibertyServiceProvider, SPOptionsIdPPolicy, LibertyFederation, KeyValue, LibertySession, SAMLAttribute) -from authentic2.decorators import to_iter from authentic2.attributes_ng.engine import get_service_attributes from . import admin_views logger = logging.getLogger(__name__) + class LibertyServiceProviderInline(admin.StackedInline): model = LibertyServiceProvider + class TextAndFileWidget(forms.widgets.MultiWidget): def __init__(self, attrs=None): - widgets = (forms.widgets.Textarea(), - forms.widgets.FileInput(),) + widgets = (forms.widgets.Textarea(), forms.widgets.FileInput()) super(TextAndFileWidget, self).__init__(widgets, attrs) def decompress(self, value): @@ -58,29 +74,25 @@ class TextAndFileWidget(forms.widgets.MultiWidget): class LibertyProviderForm(ModelForm): - metadata = forms.CharField(required=True, widget=TextAndFileWidget, - label=_('Metadata')) - public_key = forms.CharField(required=False, widget=TextAndFileWidget, - label=_('Public key')) - ssl_certificate = forms.CharField(required=False, widget=TextAndFileWidget, - label=_('SSL certificate')) - ca_cert_chain = forms.CharField(required=False, widget=TextAndFileWidget, - label=_('Certificate chain')) + metadata = forms.CharField(required=True, widget=TextAndFileWidget, label=_('Metadata')) + public_key = forms.CharField(required=False, widget=TextAndFileWidget, label=_('Public key')) + ssl_certificate = forms.CharField(required=False, widget=TextAndFileWidget, label=_('SSL certificate')) + ca_cert_chain = forms.CharField(required=False, widget=TextAndFileWidget, label=_('Certificate chain')) class Meta: model = LibertyProvider fields = [ - 'name', - 'slug', - 'ou', - 'entity_id', - 'entity_id_sha1', - 'federation_source', - 'metadata_url', - 'metadata', - 'public_key', - 'ssl_certificate', - 'ca_cert_chain', + 'name', + 'slug', + 'ou', + 'entity_id', + 'entity_id_sha1', + 'federation_source', + 'metadata_url', + 'metadata', + 'public_key', + 'ssl_certificate', + 'ca_cert_chain', ] @@ -93,15 +105,13 @@ def update_metadata(modeladmin, request, queryset): provider.update_metadata() except ValidationError as e: params = { - 'name': provider, - 'error_msg': u', '.join(e.messages) + 'name': provider, + 'error_msg': u', '.join(e.messages) } - messages.error(request, _('Updating SAML provider %(name)s failed: ' - '%(error_msg)s') % params) + messages.error(request, _('Updating SAML provider %(name)s failed: ' '%(error_msg)s') % params) else: count += 1 - messages.info(request, _('%(count)d on %(total)d SAML providers updated') % { - 'count': count, 'total': total}) + messages.info(request, _('%(count)d on %(total)d SAML providers updated') % {'count': count, 'total': total}) class SAMLAttributeInlineForm(forms.ModelForm): @@ -115,13 +125,14 @@ class SAMLAttributeInlineForm(forms.ModelForm): class Meta: model = SAMLAttribute fields = [ - 'name_format', - 'name', - 'friendly_name', - 'attribute_name', - 'enabled', + 'name_format', + 'name', + 'friendly_name', + 'attribute_name', + 'enabled', ] + class SAMLAttributeInlineAdmin(GenericTabularInline): model = SAMLAttribute form = SAMLAttributeInlineForm @@ -135,28 +146,29 @@ class SAMLAttributeInlineAdmin(GenericTabularInline): kwargs['form'] = NewForm return super(SAMLAttributeInlineAdmin, self).get_formset(request, obj=obj, **kwargs) + class LibertyProviderAdmin(admin.ModelAdmin): form = LibertyProviderForm list_display = ('name', 'ou', 'slug', 'entity_id') search_fields = ('name', 'entity_id') - readonly_fields = ('entity_id','protocol_conformance','entity_id_sha1','federation_source') + readonly_fields = ('entity_id', 'protocol_conformance', 'entity_id_sha1', 'federation_source') fieldsets = ( - (None, { - 'fields' : ('name', 'slug', 'ou', 'entity_id', 'entity_id_sha1','federation_source') - }), - (_('Metadata files'), { - 'fields': ('metadata_url', 'metadata', 'public_key', 'ssl_certificate', 'ca_cert_chain') - }), + (None, { + 'fields': ('name', 'slug', 'ou', 'entity_id', 'entity_id_sha1', 'federation_source') + }), + (_('Metadata files'), { + 'fields': ('metadata_url', 'metadata', 'public_key', 'ssl_certificate', 'ca_cert_chain') + }), ) inlines = [ - LibertyServiceProviderInline, - SAMLAttributeInlineAdmin, + LibertyServiceProviderInline, + SAMLAttributeInlineAdmin, ] - actions = [ update_metadata ] + actions = [update_metadata] prepopulated_fields = {'slug': ('name',)} list_filter = ( - 'service_provider__sp_options_policy', - 'service_provider__enabled', + 'service_provider__sp_options_policy', + 'service_provider__enabled', ) def get_urls(self): @@ -165,9 +177,10 @@ class LibertyProviderAdmin(admin.ModelAdmin): url(r'^add-from-url/$', self.admin_site.admin_view(admin_views.AddLibertyProviderFromUrlView.as_view(model_admin=self)), name='saml_libertyprovider_add_from_url'), - ] + urls + ] + urls return urls + class LibertyFederationAdmin(admin.ModelAdmin): search_fields = ('name_id_content', 'user__username') list_display = ('user', 'creation', 'last_modification', 'name_id_content', 'format', 'sp') @@ -179,24 +192,25 @@ class LibertyFederationAdmin(admin.ModelAdmin): name_id_format = u'\u2026' + name_id_format[-12:] return name_id_format + class SPOptionsIdPPolicyAdmin(admin.ModelAdmin): - inlines = [ SAMLAttributeInlineAdmin ] + inlines = [SAMLAttributeInlineAdmin] fields = ( - 'name', - 'enabled', - 'prefered_assertion_consumer_binding', - 'encrypt_nameid', - 'encrypt_assertion', - 'authn_request_signed', - 'idp_initiated_sso', - 'default_name_id_format', - 'accepted_name_id_format', - 'ask_user_consent', - 'accept_slo', - 'forward_slo', - 'needs_iframe_logout', - 'iframe_logout_timeout', - 'http_method_for_slo_request', + 'name', + 'enabled', + 'prefered_assertion_consumer_binding', + 'encrypt_nameid', + 'encrypt_assertion', + 'authn_request_signed', + 'idp_initiated_sso', + 'default_name_id_format', + 'accepted_name_id_format', + 'ask_user_consent', + 'accept_slo', + 'forward_slo', + 'needs_iframe_logout', + 'iframe_logout_timeout', + 'http_method_for_slo_request', ) admin.site.register(SPOptionsIdPPolicy, SPOptionsIdPPolicyAdmin) diff --git a/src/authentic2/saml/admin_views.py b/src/authentic2/saml/admin_views.py index 27bcd79f..c67a76a0 100644 --- a/src/authentic2/saml/admin_views.py +++ b/src/authentic2/saml/admin_views.py @@ -1,8 +1,25 @@ +# 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 . + from django.core.urlresolvers import reverse from django.views.generic import FormView from .forms import AddLibertyProviderFromUrlForm + class AdminAddFormViewMixin(object): model_admin = None @@ -11,9 +28,11 @@ class AdminAddFormViewMixin(object): ctx.update({ 'app_label': self.model_admin.model._meta.app_label, 'has_change_permission': self.model_admin.has_change_permission(self.request), - 'opts': self.model_admin.model._meta }) + 'opts': self.model_admin.model._meta + }) return ctx + class AddLibertyProviderFromUrlView(AdminAddFormViewMixin, FormView): form_class = AddLibertyProviderFromUrlForm template_name = 'admin/saml/libertyprovider/add_from_url.html' @@ -27,7 +46,5 @@ class AddLibertyProviderFromUrlView(AdminAddFormViewMixin, FormView): def form_valid(self, form): form.save() - self.success_url = reverse( - 'admin:saml_libertyprovider_change', - args=(form.instance.id,)) + self.success_url = reverse('admin:saml_libertyprovider_change', args=(form.instance.id,)) return super(AddLibertyProviderFromUrlView, self).form_valid(form) diff --git a/src/authentic2/saml/app_settings.py b/src/authentic2/saml/app_settings.py index 0c0c4a18..072c2101 100644 --- a/src/authentic2/saml/app_settings.py +++ b/src/authentic2/saml/app_settings.py @@ -1,8 +1,26 @@ +# 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 sys from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from django.utils.translation import ugettext_lazy as _ + class AppSettings(object): __PREFIX = 'SAML_' __NAMES = ('ALLOWED_FEDERATION_MODE', 'DEFAULT_FEDERATION_MODE') @@ -16,34 +34,32 @@ class AppSettings(object): @classmethod def get_choices(cls, app_settings): - l = [] + choices = [] for choice in cls.choices: if choice[0] in app_settings.ALLOWED_FEDERATION_MODE: - l.append(choice) - return l + choices.append(choice) + return choices @classmethod def get_default(cls, app_settings): return app_settings.DEFAULT_FEDERATION_MODE __DEFAULTS = { - 'ALLOWED_FEDERATION_MODE': (FEDERATION_MODE.EXPLICIT, - FEDERATION_MODE.IMPLICIT), - 'DEFAULT_FEDERATION_MODE': FEDERATION_MODE.EXPLICIT, + 'ALLOWED_FEDERATION_MODE': (FEDERATION_MODE.EXPLICIT, FEDERATION_MODE.IMPLICIT), + 'DEFAULT_FEDERATION_MODE': FEDERATION_MODE.EXPLICIT, } - def __settings(self, name): full_name = self.__PREFIX + name if name not in self.__NAMES: - raise AttributeError('unknown settings '+full_name) + raise AttributeError('unknown settings ' + full_name) try: if name in self.__DEFAULTS: return getattr(settings, full_name, self.__DEFAULTS[name]) else: return getattr(settings, full_name) except AttributeError: - raise ImproperlyConfigured('missing settings '+full_name) + raise ImproperlyConfigured('missing settings ' + full_name) def __getattr__(self, name): return self.__settings(name) diff --git a/src/authentic2/saml/common.py b/src/authentic2/saml/common.py index 8846b0ca..1e8d75b2 100644 --- a/src/authentic2/saml/common.py +++ b/src/authentic2/saml/common.py @@ -1,3 +1,19 @@ +# 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 os.path import logging import re @@ -24,22 +40,14 @@ from authentic2.idp.saml import app_settings from .. import nonce AUTHENTIC_STATUS_CODE_NS = "http://authentic.entrouvert.org/status_code/" -AUTHENTIC_SAME_ID_SENTINEL = \ - 'urn:authentic.entrouvert.org:same-as-provider-entity-id' -AUTHENTIC_STATUS_CODE_UNKNOWN_PROVIDER = AUTHENTIC_STATUS_CODE_NS + \ - "UnknownProvider" -AUTHENTIC_STATUS_CODE_MISSING_NAMEID = AUTHENTIC_STATUS_CODE_NS + \ - "MissingNameID" -AUTHENTIC_STATUS_CODE_MISSING_SESSION_INDEX = AUTHENTIC_STATUS_CODE_NS + \ - "MissingSessionIndex" -AUTHENTIC_STATUS_CODE_UNKNOWN_SESSION = AUTHENTIC_STATUS_CODE_NS + \ - "UnknownSession" -AUTHENTIC_STATUS_CODE_MISSING_DESTINATION = AUTHENTIC_STATUS_CODE_NS + \ - "MissingDestination" -AUTHENTIC_STATUS_CODE_INTERNAL_SERVER_ERROR = AUTHENTIC_STATUS_CODE_NS + \ - "InternalServerError" -AUTHENTIC_STATUS_CODE_UNAUTHORIZED = AUTHENTIC_STATUS_CODE_NS + \ - "Unauthorized" +AUTHENTIC_SAME_ID_SENTINEL = 'urn:authentic.entrouvert.org:same-as-provider-entity-id' +AUTHENTIC_STATUS_CODE_UNKNOWN_PROVIDER = AUTHENTIC_STATUS_CODE_NS + "UnknownProvider" +AUTHENTIC_STATUS_CODE_MISSING_NAMEID = AUTHENTIC_STATUS_CODE_NS + "MissingNameID" +AUTHENTIC_STATUS_CODE_MISSING_SESSION_INDEX = AUTHENTIC_STATUS_CODE_NS + "MissingSessionIndex" +AUTHENTIC_STATUS_CODE_UNKNOWN_SESSION = AUTHENTIC_STATUS_CODE_NS + "UnknownSession" +AUTHENTIC_STATUS_CODE_MISSING_DESTINATION = AUTHENTIC_STATUS_CODE_NS + "MissingDestination" +AUTHENTIC_STATUS_CODE_INTERNAL_SERVER_ERROR = AUTHENTIC_STATUS_CODE_NS + "InternalServerError" +AUTHENTIC_STATUS_CODE_UNAUTHORIZED = AUTHENTIC_STATUS_CODE_NS + "Unauthorized" logger = logging.getLogger(__name__) @@ -225,7 +233,7 @@ def check_id_and_issue_instant(request_response_or_assertion, now=None): delta = datetime.timedelta(seconds=NONCE_TIMEOUT) if not (now - delta <= issue_instant < now + delta): logger.warning('IssueInstant %s not in the interval [%s, %s[', - issue_instant, now-delta, now+delta) + issue_instant, now - delta, now+delta) return False except ValueError: logger.error('Unable to parse an IssueInstant: %r', issue_instant) @@ -235,7 +243,7 @@ def check_id_and_issue_instant(request_response_or_assertion, now=None): if _id is None: logger.warning('missing ID') return False - if not nonce.accept_nonce(_id, 'SAML', 2*NONCE_TIMEOUT): + if not nonce.accept_nonce(_id, 'SAML', 2 * NONCE_TIMEOUT): logger.warning("ID '%r' already used, request/response/assertion " "refused", _id) return False @@ -266,7 +274,7 @@ END_IDENTITY_DUMP = '''''' def federations_to_identity_dump(self_entity_id, federations): - l = [START_IDENTITY_DUMP] + l = [START_IDENTITY_DUMP] # noqa: E741 for federation in federations: name_id_qualifier = federation.name_id_qualifier name_id_sp_name_qualifier = federation.name_id_sp_name_qualifier @@ -325,10 +333,9 @@ def retrieve_metadata_and_create(request, provider_id, sp_or_idp): return None logger.debug('loaded %d bytes', len(metadata)) try: - metadata = six.text_type(metadata, 'utf8') - except: - logging.error('SAML metadata autoload: retrieved metadata for entity ' - 'id %s is not UTF-8', provider_id) + metadata = six.text_type(metadata, 'utf-8') + except UnicodeDecodeError: + logging.error('SAML metadata autoload: retrieved metadata for entity id %s is not UTF-8', provider_id) return None p = LibertyProvider(metadata=metadata) try: @@ -337,7 +344,7 @@ def retrieve_metadata_and_create(request, provider_id, sp_or_idp): logging.error('SAML metadata autoload: retrieved metadata for entity ' 'id %s are invalid, %s', provider_id, e.args) return None - except: + except Exception: logging.exception('SAML metadata autoload: retrieved metadata ' 'validation raised an unknown exception') return None @@ -419,7 +426,7 @@ def lookup_federation_by_name_identifier(name_id=None, profile=None): kwargs = models.nameid2kwargs(name_id) try: return LibertyFederation.objects.get(**kwargs) - except: + except LibertyFederation.DoesNotExist: return None @@ -431,7 +438,7 @@ def lookup_federation_by_name_id_and_provider_id(name_id, provider_id): .identity_provider try: return LibertyFederation.objects.get(user__isnull=False, **kwargs) - except: + except LibertyFederation.DoesNotExist: return None diff --git a/src/authentic2/saml/fields.py b/src/authentic2/saml/fields.py index 1b8d6ba1..ff833c5f 100644 --- a/src/authentic2/saml/fields.py +++ b/src/authentic2/saml/fields.py @@ -1,17 +1,31 @@ +# 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 . + try: import cPickle as pickle except ImportError: import pickle import six -import django from django import forms from django.db import models from django.db.models.lookups import Exact, In from django.core.exceptions import ValidationError from django.utils.text import capfirst -from django.contrib.humanize.templatetags.humanize \ - import apnumber +from django.contrib.humanize.templatetags.humanize import apnumber from django.template.defaultfilters import pluralize # This is a copy of http://djangosnippets.org/snippets/513/ @@ -26,6 +40,7 @@ from django.template.defaultfilters import pluralize # # Initial author: Oliver Beattie + class PickledObject(six.binary_type): """A subclass of string so it can be told whether a string is a pickled object or not (if the object is an instance of this class @@ -56,7 +71,7 @@ class PickledObjectField(models.Field): else: try: return pickle.loads(six.binary_type(value)) - except: + except Exception: # If an error was raised, just return the plain value return value @@ -111,6 +126,7 @@ PickledObjectField.register_lookup(PickledObjectFieldIn) # # Initial author: Daniel Roseman + class MultiSelectFormField(forms.MultipleChoiceField): widget = forms.CheckboxSelectMultiple @@ -122,12 +138,12 @@ class MultiSelectFormField(forms.MultipleChoiceField): if not value and self.required: raise forms.ValidationError(self.error_messages['required']) if value and self.max_choices and len(value) > self.max_choices: - raise forms.ValidationError('You must select a maximum of %s choice%s.' - % (apnumber(self.max_choices), pluralize(self.max_choices))) + raise forms.ValidationError('You must select a maximum of %s choice %s.' % ( + apnumber(self.max_choices), pluralize(self.max_choices))) return value -class MultiSelectField(models.Field): +class MultiSelectField(models.Field): def get_internal_type(self): return "CharField" @@ -139,8 +155,12 @@ class MultiSelectField(models.Field): def formfield(self, **kwargs): # don't call super, as that overrides default widget if it has choices - defaults = {'required': not self.blank, 'label': capfirst(self.verbose_name), - 'help_text': self.help_text, 'choices':self.choices} + defaults = { + 'required': not self.blank, + 'label': capfirst(self.verbose_name), + 'help_text': self.help_text, + 'choices': self.choices, + } if self.has_default(): defaults['initial'] = self.get_default() defaults.update(kwargs) @@ -155,8 +175,8 @@ class MultiSelectField(models.Field): def validate(self, value, model_instance): out = set() if self.choices: - out |= set([option_key for option_key,_ in self.choices]) - out = set(value)-out + out |= set([option_key for option_key, _ in self.choices]) + out = set(value) - out if out: raise ValidationError(self.error_messages['invalid_choice'] % ','.join(list(out))) if not value and not self.blank: @@ -175,5 +195,6 @@ class MultiSelectField(models.Field): def contribute_to_class(self, cls, name): super(MultiSelectField, self).contribute_to_class(cls, name) if self.choices: - func = lambda self, fieldname = name, choicedict = dict(self.choices):",".join([choicedict.get(value,value) for value in getattr(self,fieldname)]) + def func(self, fieldname=name, choicedict=dict(self.choices)): + return ",".join([choicedict.get(value, value) for value in getattr(self, fieldname)]) setattr(cls, 'get_%s_display' % self.name, func) diff --git a/src/authentic2/saml/forms.py b/src/authentic2/saml/forms.py index 779bb668..b5dd3868 100644 --- a/src/authentic2/saml/forms.py +++ b/src/authentic2/saml/forms.py @@ -1,3 +1,19 @@ +# 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 xml.etree.ElementTree as ET import requests @@ -59,7 +75,7 @@ class AddLibertyProviderFromUrlForm(forms.Form): return cleaned_data def save(self): - if not self.instance is None: + if self.instance is not None: self.instance.save() for child in self.childs: child.liberty_provider = self.instance diff --git a/src/authentic2/saml/lasso_helper.py b/src/authentic2/saml/lasso_helper.py index 697d4a8b..527cebc8 100644 --- a/src/authentic2/saml/lasso_helper.py +++ b/src/authentic2/saml/lasso_helper.py @@ -1,16 +1,34 @@ -import xml.etree.ElementTree as etree +# 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 xml.etree.ElementTree as etree LASSO_NS = 'http://www.entrouvert.org/namespaces/lasso/0.0' SAML_ASSERTION_NS = 'urn:oasis:names:tc:SAML:2.0:assertion' + def lasso_elt(name): return '{{{0}}}{1}'.format(LASSO_NS, name) + def samla_elt(name): return '{{{0}}}{1}'.format(SAML_ASSERTION_NS, name) + SESSION_ELT = lasso_elt('Session') NID_AND_SESSION_INDEX = lasso_elt('NidAndSessionIndex') VERSION_AT = 'Version' @@ -23,12 +41,13 @@ FORMAT_AT = 'Format' NAME_QUALIFIER_AT = 'NameQualifier' SP_NAME_QUALIFIER_AT = 'SPNameQualifier' + def build_name_id(name_id, treebuilder=None): if treebuilder is None: tb = etree.TreeBuilder() else: tb = treebuilder - attrs = { FORMAT_AT: name_id['name_id_format'] } + attrs = {FORMAT_AT: name_id['name_id_format']} if 'name_id_qualifier' in name_id: attrs[NAME_QUALIFIER_AT] = name_id['name_id_qualifier'] if 'name_id_sp_name_qualifier' in name_id: @@ -39,6 +58,7 @@ def build_name_id(name_id, treebuilder=None): if treebuilder is None: return tb.close() + def buid_session_dump(sessions): tb = etree.TreeBuilder() tb.start(SESSION_ELT, {VERSION_AT: '2'}) diff --git a/src/authentic2/saml/management/commands/mapping.py b/src/authentic2/saml/management/commands/mapping.py index de900de0..68d4a11a 100644 --- a/src/authentic2/saml/management/commands/mapping.py +++ b/src/authentic2/saml/management/commands/mapping.py @@ -1,3 +1,19 @@ +# 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 . + ''' Authentic 2 - Versatile Identity Server diff --git a/src/authentic2/saml/management/commands/sync-metadata.py b/src/authentic2/saml/management/commands/sync-metadata.py index 029ef2ca..6a45f0ba 100644 --- a/src/authentic2/saml/management/commands/sync-metadata.py +++ b/src/authentic2/saml/management/commands/sync-metadata.py @@ -1,3 +1,19 @@ +# 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 . + from __future__ import print_function import sys @@ -7,12 +23,12 @@ import requests import warnings from django.core.management.base import BaseCommand, CommandError +from django.db.transaction import atomic from django.template.defaultfilters import slugify from django.utils import six from django.utils.translation import gettext as _ from django.contrib.contenttypes.models import ContentType -from authentic2.compat import commit_on_success from authentic2.compat_lasso import lasso from authentic2.saml.shibboleth.afp_parser import parse_attribute_filters_file from authentic2.saml.models import (LibertyProvider, SAMLAttribute, LibertyServiceProvider, @@ -93,7 +109,7 @@ def check_support_saml2(tree): def text_child(tree, tag, default=''): elt = tree.find(tag) - return elt.text if not elt is None else default + return elt.text if elt is not None else default def load_acs(tree, provider, pks, verbosity): @@ -129,8 +145,10 @@ def load_acs(tree, provider, pks, verbosity): attribute, created = SAMLAttribute.objects.get_or_create( defaults=defaults, **kwargs) if created and verbosity > 1: - print(_('Created new attribute %(name)s for %(provider)s') % \ - {'name': oid, 'provider': provider}) + print(_('Created new attribute %(name)s for %(provider)s') % { + 'name': oid, + 'provider': provider + }) pks.append(attribute.pk) except SAMLAttribute.MultipleObjectsReturned: pks.extend(SAMLAttribute.objects.filter(**kwargs).values_list('pk', flat=True)) @@ -201,8 +219,10 @@ def load_one_entity(tree, options, sp_policy=None, afp=None): kwargs, defaults = build_saml_attribute_kwargs(provider, name) if not kwargs: if verbosity > 1: - print(_('Unable to find an LDAP definition for attribute %(name)s on %(provider)s') % \ - {'name': name, 'provider': provider}, file=sys.stderr) + print(_('Unable to find an LDAP definition for attribute %(name)s on %(provider)s') % { + 'name': name, + 'provider': provider + }, file=sys.stderr) continue # create object with default attribute mapping to the same name # as the attribute if no SAMLAttribute model already exists, @@ -211,8 +231,10 @@ def load_one_entity(tree, options, sp_policy=None, afp=None): attribute, created = SAMLAttribute.objects.get_or_create( defaults=defaults, **kwargs) if created and verbosity > 1: - print(_('Created new attribute %(name)s for %(provider)s') - % {'name': name, 'provider': provider}) + print(_('Created new attribute %(name)s for %(provider)s') % { + 'name': name, + 'provider': provider + }) pks.append(attribute.pk) except SAMLAttribute.MultipleObjectsReturned: pks.extend(SAMLAttribute.objects.filter( @@ -290,7 +312,7 @@ Any other kind of attribute filter policy is unsupported. help='When creating a new provider, make it disabled by default.' ) - @commit_on_success + @atomic def handle(self, *args, **options): verbosity = int(options['verbosity']) source = options['source'] @@ -299,7 +321,7 @@ Any other kind of attribute filter policy is unsupported. try: if source is not None: source.decode('ascii') - except: + except UnicodeDecodeError: raise CommandError('--source MUST be an ASCII string value') if metadata_file_path.startswith('http://') or metadata_file_path.startswith('https://'): response = requests.get(metadata_file_path) @@ -308,8 +330,8 @@ Any other kind of attribute filter policy is unsupported. metadata_file = six.BytesIO(response.content) else: try: - metadata_file = file(metadata_file_path) - except: + metadata_file = open(metadata_file_path) + except IOError: raise CommandError('Unable to open file %s' % metadata_file_path) try: @@ -335,7 +357,7 @@ Any other kind of attribute filter policy is unsupported. if verbosity > 1: print('Service providers are set with the following SAML2 \ options policy: %s' % sp_policy) - except: + except SPOptionsIdPPolicy.DoesNoextExist: if verbosity > 0: print(_('SAML2 service provider options ' 'policy with name %s not found') % sp_policy_name, @@ -356,13 +378,12 @@ Any other kind of attribute filter policy is unsupported. sp_policy=sp_policy, afp=afp) loaded.append(entity_descriptor.get(ENTITY_ID)) - except Exception as e: + except Exception: if not options['ignore-errors']: raise if verbosity > 0: - print((_('Failed to load entity descriptor for %s') - % entity_descriptor.get(ENTITY_ID)), - file=sys.stderr) + print(_('Failed to load entity descriptor for %s') % entity_descriptor.get(ENTITY_ID), + file=sys.stderr) raise CommandError() if options['source']: if options['delete']: diff --git a/src/authentic2/saml/managers.py b/src/authentic2/saml/managers.py index 7ff726c3..45bf7063 100644 --- a/src/authentic2/saml/managers.py +++ b/src/authentic2/saml/managers.py @@ -1,3 +1,19 @@ +# 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 base64 import binascii import datetime @@ -17,6 +33,7 @@ from ..managers import GetBySlugQuerySet, GenericManager federation_delete = Signal() + class SessionLinkedQuerySet(QuerySet): def cleanup(self): engine = import_module(settings.SESSION_ENGINE) @@ -28,6 +45,7 @@ class SessionLinkedQuerySet(QuerySet): SessionLinkedManager = models.Manager.from_queryset(SessionLinkedQuerySet) + class LibertyFederationManager(models.Manager): def cleanup(self): for federation in self.filter(user__isnull=True): @@ -49,7 +67,7 @@ class LibertyFederationManager(models.Manager): class LibertyArtifactManager(models.Manager): def cleanup(self): expire = getattr(settings, 'SAML2_ARTIFACT_EXPIRATION', 600) - before = now()-datetime.timedelta(seconds=expire) + before = now() - datetime.timedelta(seconds=expire) self.filter(creation__lt=before).delete() @@ -59,7 +77,7 @@ class LibertyProviderQueryset(GetBySlugQuerySet): 25-th byte of the given artifact''' try: artifact = base64.b64decode(artifact) - except: + except (TypeError, ValueError): raise ValueError('artifact %r is not a base64 encoded value') entity_id_sha1 = artifact[4:24] entity_id_sha1 = binascii.hexlify(entity_id_sha1) @@ -79,18 +97,21 @@ class LibertyProviderQueryset(GetBySlugQuerySet): LibertyProviderManager = models.Manager.from_queryset(LibertyProviderQueryset) + class LibertySessionQuerySet(SessionLinkedQuerySet): def to_session_dump(self): - sessions = self.values('provider_id', - 'session_index', - 'name_id_qualifier', - 'name_id_format', - 'name_id_content', - 'name_id_sp_name_qualifier') + sessions = self.values( + 'provider_id', + 'session_index', + 'name_id_qualifier', + 'name_id_format', + 'name_id_content', + 'name_id_sp_name_qualifier') return lasso_helper.build_session_dump(sessions) LibertySessionManager = models.Manager.from_queryset(LibertySessionQuerySet) + class GetByLibertyProviderManager(models.Manager): def get_by_natural_key(self, slug): from .models import LibertyProvider @@ -103,6 +124,7 @@ class GetByLibertyProviderManager(models.Manager): raise self.model.DoesNotExist return self.create(liberty_provider=liberty_provider) + class SAMLAttributeManager(GenericManager): def get_by_natural_key(self, ct_nk, provider_nk, name_format, name, friendly_name, attribute_name): from .models import SAMLAttribute diff --git a/src/authentic2/saml/models.py b/src/authentic2/saml/models.py index 4c1049b1..8b6f9d49 100644 --- a/src/authentic2/saml/models.py +++ b/src/authentic2/saml/models.py @@ -1,7 +1,21 @@ +# 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 xml.etree.ElementTree as etree import hashlib -import numbers -import datetime import requests from authentic2.compat_lasso import lasso @@ -30,22 +44,26 @@ from . import app_settings, managers from .. import managers as a2_managers from ..models import Service + def metadata_validator(meta): - provider=lasso.Provider.newFromBuffer(lasso.PROVIDER_ROLE_ANY, meta.encode('utf8')) + provider = lasso.Provider.newFromBuffer(lasso.PROVIDER_ROLE_ANY, meta.encode('utf8')) if not provider: raise ValidationError(_('Invalid metadata file')) XML_NS = 'http://www.w3.org/XML/1998/namespace' + def get_lang(etree): return etree.get('{%s}lang' % XML_NS) + def ls_find(ls, value): try: return ls.index(value) except ValueError: return -1 -def get_prefered_content(etrees, languages = [None, 'en']): + +def get_prefered_content(etrees, languages=[None, 'en']): '''Sort XML nodes by their xml:lang attribute using languages as the ascending partial order of language identifiers @@ -65,6 +83,7 @@ def get_prefered_content(etrees, languages = [None, 'en']): best_score = ls_find(languages, get_lang(tree)) return best.text + def organization_name(provider): '''Extract an organization name from a SAMLv2 metadata organization XML fragment. @@ -72,65 +91,78 @@ def organization_name(provider): try: organization_xml = provider.organization organization = etree.XML(organization_xml) - o_display_name = organization.findall('{%s}OrganizationDisplayName' % - lasso.SAML2_METADATA_HREF) + o_display_name = organization.findall('{%s}OrganizationDisplayName' % lasso.SAML2_METADATA_HREF) if o_display_name: return get_prefered_content(o_display_name) - o_name = organization.findall('{%s}OrganizationName' % - lasso.SAML2_METADATA_HREF) + o_name = organization.findall('{%s}OrganizationName' % lasso.SAML2_METADATA_HREF) if o_name: return get_prefered_content(o_name) - except: + except Exception: return provider.providerId else: return provider.providerId # TODO: Remove this in LibertyServiceProvider ASSERTION_CONSUMER_PROFILES = ( - ('meta', _('Use the default from the metadata file')), - ('art', _('Artifact binding')), - ('post', _('POST binding'))) + ('meta', _('Use the default from the metadata file')), + ('art', _('Artifact binding')), + ('post', _('POST binding'))) DEFAULT_NAME_ID_FORMAT = 'none' # Supported name id formats NAME_ID_FORMATS = { - 'none': { 'caption': _('None'), - 'samlv2': None,}, - 'persistent': { 'caption': _('Persistent'), - 'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT,}, - 'transient': { 'caption': _("Transient"), - 'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT,}, - 'email': { 'caption': _("Email"), - 'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL,}, - 'username': { 'caption': _("Username (use with Google Apps)"), - 'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_UNSPECIFIED,}, - 'uuid': { 'caption': _("UUID"), - 'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_UNSPECIFIED,}, - 'edupersontargetedid': { 'caption': _("Use eduPersonTargetedID attribute"), - 'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT,} + 'none': { + 'caption': _('None'), + 'samlv2': None, + }, + 'persistent': { + 'caption': _('Persistent'), + 'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT, + }, + 'transient': { + 'caption': _("Transient"), + 'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT, + }, + 'email': { + 'caption': _("Email"), + 'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL, + }, + 'username': { + 'caption': _("Username (use with Google Apps)"), + 'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_UNSPECIFIED, + }, + 'uuid': { + 'caption': _("UUID"), + 'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_UNSPECIFIED, + }, + 'edupersontargetedid': { + 'caption': _("Use eduPersonTargetedID attribute"), + 'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT, + } } -NAME_ID_FORMATS_CHOICES = \ - tuple([(x, y['caption']) for x, y in NAME_ID_FORMATS.items()]) +NAME_ID_FORMATS_CHOICES = tuple([(x, y['caption']) for x, y in NAME_ID_FORMATS.items()]) + +ACCEPTED_NAME_ID_FORMAT_LENGTH = sum([len(x) for x, y in NAME_ID_FORMATS.items()]) + len(NAME_ID_FORMATS) - 1 -ACCEPTED_NAME_ID_FORMAT_LENGTH = \ - sum([len(x) for x, y in NAME_ID_FORMATS.items()]) + \ - len(NAME_ID_FORMATS) - 1 def saml2_urn_to_nidformat(urn, accepted=()): for x, y in NAME_ID_FORMATS.items(): - if accepted and not x in accepted: + if accepted and x not in accepted: continue if y['samlv2'] == urn: return x return None + def nidformat_to_saml2_urn(key): return NAME_ID_FORMATS.get(key, {}).get('samlv2') + # According to: saml-profiles-2.0-os -# The HTTP Redirect binding MUST NOT be used, as the response will typically exceed the URL length permitted by most user agents. +# The HTTP Redirect binding MUST NOT be used, as the response will typically +# exceed the URL length permitted by most user agents. BINDING_SSO_IDP = ( (lasso.SAML2_METADATA_BINDING_ARTIFACT, _('Artifact binding')), (lasso.SAML2_METADATA_BINDING_POST, _('POST binding')) @@ -144,9 +176,9 @@ HTTP_METHOD = ( SIGNATURE_VERIFY_HINT = { - lasso.PROFILE_SIGNATURE_VERIFY_HINT_MAYBE: _('Let authentic decides which signatures to check'), - lasso.PROFILE_SIGNATURE_VERIFY_HINT_FORCE: _('Always check signatures'), - lasso.PROFILE_SIGNATURE_VERIFY_HINT_IGNORE: _('Does not check signatures') } + lasso.PROFILE_SIGNATURE_VERIFY_HINT_MAYBE: _('Let authentic decides which signatures to check'), + lasso.PROFILE_SIGNATURE_VERIFY_HINT_FORCE: _('Always check signatures'), + lasso.PROFILE_SIGNATURE_VERIFY_HINT_IGNORE: _('Does not check signatures') } AUTHSAML2_UNAUTH_PERSISTENT = ( ('AUTHSAML2_UNAUTH_PERSISTENT_ACCOUNT_LINKING_BY_AUTH', @@ -169,59 +201,65 @@ class SPOptionsIdPPolicy(models.Model): Used to define SAML2 parameters employed with service providers. ''' name = models.CharField(_('name'), max_length=80, unique=True) - enabled = models.BooleanField(verbose_name = _('Enabled'), - default=False, db_index=True) + enabled = models.BooleanField(verbose_name=_('Enabled'), default=False, db_index=True) prefered_assertion_consumer_binding = models.CharField( - verbose_name = _("Prefered assertion consumer binding"), - default = 'meta', - max_length = 4, choices = ASSERTION_CONSUMER_PROFILES) - encrypt_nameid = models.BooleanField(verbose_name = _("Encrypt NameID"), - default=False) + verbose_name=_("Prefered assertion consumer binding"), + default='meta', + max_length=4, + choices=ASSERTION_CONSUMER_PROFILES) + encrypt_nameid = models.BooleanField(verbose_name=_("Encrypt NameID"), default=False) encrypt_assertion = models.BooleanField( - verbose_name = _("Encrypt Assertion"), - default=False) + verbose_name=_("Encrypt Assertion"), + default=False) authn_request_signed = models.BooleanField( - verbose_name = _("Authentication request signed"), - default=False) + verbose_name=_("Authentication request signed"), + default=False) idp_initiated_sso = models.BooleanField( - verbose_name = _("Allow IdP initiated SSO"), - default=False, db_index=True) + verbose_name=_("Allow IdP initiated SSO"), + default=False, db_index=True) # XXX: format in the metadata file, should be suffixed with a star to mark # them as special - default_name_id_format = models.CharField(max_length = 256, - default = DEFAULT_NAME_ID_FORMAT, - choices = NAME_ID_FORMATS_CHOICES) + default_name_id_format = models.CharField( + max_length=256, + default=DEFAULT_NAME_ID_FORMAT, + choices=NAME_ID_FORMATS_CHOICES) accepted_name_id_format = MultiSelectField( - verbose_name = _("NameID formats accepted"), - max_length=1024, - blank=True, choices=NAME_ID_FORMATS_CHOICES) + verbose_name=_("NameID formats accepted"), + max_length=1024, + blank=True, + choices=NAME_ID_FORMATS_CHOICES) # TODO: add clean method which checks that the LassoProvider we can create # with the metadata file support the SP role # i.e. provider.roles & lasso.PROVIDER_ROLE_SP != 0 ask_user_consent = models.BooleanField( - verbose_name = _('Ask user for consent when creating a federation'), default = False) - accept_slo = models.BooleanField(\ - verbose_name = _("Accept to receive Single Logout requests"), - default=True, db_index=True) - forward_slo = models.BooleanField(\ - verbose_name = _("Forward Single Logout requests"), - default=True) + verbose_name=_('Ask user for consent when creating a federation'), default=False) + accept_slo = models.BooleanField( + verbose_name=_("Accept to receive Single Logout requests"), + default=True, + db_index=True) + forward_slo = models.BooleanField( + verbose_name=_("Forward Single Logout requests"), + default=True) needs_iframe_logout = models.BooleanField( - verbose_name=_('needs iframe logout'), - help_text=_('logout URL are normally loaded inside an HTML tag, some service provider need to use an iframe'), - default=False) + verbose_name=_('needs iframe logout'), + help_text=_( + 'logout URL are normally loaded inside an HTML tag, some service provider need to use an iframe'), + default=False) iframe_logout_timeout = models.PositiveIntegerField( - verbose_name=_('iframe logout timeout'), - help_text=_('if iframe logout is used, it\'s the time between the ' - 'onload event for this iframe and the moment we consider its ' - 'loading to be really finished'), - default=300) + verbose_name=_('iframe logout timeout'), + help_text=_( + 'if iframe logout is used, it\'s the time between the ' + 'onload event for this iframe and the moment we consider its ' + 'loading to be really finished'), + default=300) http_method_for_slo_request = models.IntegerField( - verbose_name = _("HTTP binding for the SLO requests"), - choices = HTTP_METHOD, default = lasso.HTTP_METHOD_REDIRECT) - federation_mode = models.PositiveIntegerField(_('federation mode'), - choices=app_settings.FEDERATION_MODE.get_choices(app_settings), - default=app_settings.FEDERATION_MODE.get_default(app_settings)) + verbose_name=_("HTTP binding for the SLO requests"), + choices=HTTP_METHOD, + default=lasso.HTTP_METHOD_REDIRECT) + federation_mode = models.PositiveIntegerField( + _('federation mode'), + choices=app_settings.FEDERATION_MODE.get_choices(app_settings), + default=app_settings.FEDERATION_MODE.get_default(app_settings)) attributes = GenericRelation('SAMLAttribute') objects = a2_managers.GetByNameManager() @@ -236,40 +274,38 @@ class SPOptionsIdPPolicy(models.Model): def __str__(self): return self.name + @six.python_2_unicode_compatible class SAMLAttribute(models.Model): ATTRIBUTE_NAME_FORMATS = ( - ('basic', 'Basic'), - ('uri', 'URI'), - ('unspecified', 'Unspecified'), + ('basic', 'Basic'), + ('uri', 'URI'), + ('unspecified', 'Unspecified'), ) objects = managers.SAMLAttributeManager() - content_type = models.ForeignKey(ContentType, - verbose_name=_('content type')) - object_id = models.PositiveIntegerField( - verbose_name=_('object identifier')) + content_type = models.ForeignKey(ContentType, verbose_name=_('content type')) + object_id = models.PositiveIntegerField(verbose_name=_('object identifier')) provider = GenericForeignKey('content_type', 'object_id') name_format = models.CharField( - max_length=64, - verbose_name=_('name format'), - default='basic', - choices=ATTRIBUTE_NAME_FORMATS) + max_length=64, + verbose_name=_('name format'), + default='basic', + choices=ATTRIBUTE_NAME_FORMATS) name = models.CharField( - max_length=128, - verbose_name=_('name'), - blank=True, - help_text=_('the local attribute name is used if left blank')) + max_length=128, + verbose_name=_('name'), + blank=True, + help_text=_('the local attribute name is used if left blank')) friendly_name = models.CharField( - max_length=64, - verbose_name=_('friendly name'), - blank=True) - attribute_name = models.CharField(max_length=64, - verbose_name=_('attribute name')) + max_length=64, + verbose_name=_('friendly name'), + blank=True) + attribute_name = models.CharField(max_length=64, verbose_name=_('attribute name')) enabled = models.BooleanField( - verbose_name=_('enabled'), - default=True, - blank=True) + verbose_name=_('enabled'), + default=True, + blank=True) def clean(self): super(SAMLAttribute, self).clean() @@ -287,7 +323,7 @@ class SAMLAttribute(models.Model): raise NotImplementedError def to_tuples(self, ctx): - if not self.attribute_name in ctx: + if self.attribute_name not in ctx: return name_format = self.name_format_uri() name = self.name @@ -302,31 +338,37 @@ class SAMLAttribute(models.Model): def natural_key(self): if not hasattr(self.provider, 'natural_key'): return self.id - return (self.content_type.natural_key(), self.provider.natural_key(), self.name_format, self.name, self.friendly_name, self.attribute_name) + return (self.content_type.natural_key(), + self.provider.natural_key(), + self.name_format, + self.name, + self.friendly_name, + self.attribute_name) class Meta: - unique_together = (('content_type', 'object_id', 'name_format', 'name', - 'friendly_name', 'attribute_name'),) + unique_together = ( + ('content_type', 'object_id', 'name_format', 'name', 'friendly_name', 'attribute_name'), + ) @six.python_2_unicode_compatible class LibertyProvider(Service): - entity_id = models.URLField(max_length=256, unique=True, - verbose_name=_('Entity ID')) - entity_id_sha1 = models.CharField(max_length=40, blank=True, - verbose_name=_('Entity ID SHA1')) - metadata_url = models.URLField(max_length=256, blank=True, - verbose_name=_('Metadata URL')) + entity_id = models.URLField(max_length=256, unique=True, verbose_name=_('Entity ID')) + entity_id_sha1 = models.CharField(max_length=40, blank=True, verbose_name=_('Entity ID SHA1')) + metadata_url = models.URLField(max_length=256, blank=True, verbose_name=_('Metadata URL')) protocol_conformance = models.IntegerField( - choices=((lasso.PROTOCOL_SAML_2_0, 'SAML 2.0'),), - verbose_name=_('Protocol conformance')) - metadata = models.TextField(validators = [ metadata_validator ]) + choices=((lasso.PROTOCOL_SAML_2_0, 'SAML 2.0'),), + verbose_name=_('Protocol conformance')) + metadata = models.TextField(validators=[metadata_validator]) # All following field must be PEM formatted textual data public_key = models.TextField(blank=True) ssl_certificate = models.TextField(blank=True) ca_cert_chain = models.TextField(blank=True) - federation_source = models.CharField(max_length=64, blank=True, null=True, - verbose_name=_('Federation source')) + federation_source = models.CharField( + max_length=64, + blank=True, + null=True, + verbose_name=_('Federation source')) attributes = GenericRelation(SAMLAttribute) @@ -338,8 +380,7 @@ class LibertyProvider(Service): def save(self, *args, **kwargs): '''Update the SHA1 hash of the entity_id when saving''' if self.protocol_conformance == 3: - self.entity_id_sha1 = hashlib.sha1(self.entity_id.encode('ascii')) \ - .hexdigest() + self.entity_id_sha1 = hashlib.sha1(self.entity_id.encode('ascii')).hexdigest() super(LibertyProvider, self).save(*args, **kwargs) def clean(self): @@ -374,6 +415,7 @@ class LibertyProvider(Service): verbose_name = _('SAML provider') verbose_name_plural = _('SAML providers') + def get_all_custom_or_default(instance, name): model = instance._meta.get_field(name).rel.to try: @@ -388,27 +430,28 @@ def get_all_custom_or_default(instance, name): except ObjectDoesNotExist: raise RuntimeError('Default %s is missing' % model) + # TODO: The IdP must look to the preferred binding order for sso in the SP metadata (AssertionConsumerService) # expect if the protocol for response is defined in the request (ProtocolBinding attribute) @six.python_2_unicode_compatible class LibertyServiceProvider(models.Model): - liberty_provider = models.OneToOneField(LibertyProvider, - primary_key = True, related_name = 'service_provider') - enabled = models.BooleanField(verbose_name = _('Enabled'), - default=False, db_index=True) - enable_following_sp_options_policy = models.BooleanField(verbose_name = \ - _('The following options policy will apply except if a policy for all service provider is defined.'), + liberty_provider = models.OneToOneField(LibertyProvider, primary_key=True, related_name='service_provider') + enabled = models.BooleanField(verbose_name=_('Enabled'), default=False, db_index=True) + enable_following_sp_options_policy = models.BooleanField( + verbose_name=_('The following options policy will apply except ' + 'if a policy for all service provider is defined.'), default=False) - sp_options_policy = models.ForeignKey(SPOptionsIdPPolicy, - related_name="sp_options_policy", - verbose_name=_('service provider options policy'), blank=True, - null=True, - on_delete=models.SET_NULL) + sp_options_policy = models.ForeignKey( + SPOptionsIdPPolicy, + related_name="sp_options_policy", + verbose_name=_('service provider options policy'), blank=True, + null=True, + on_delete=models.SET_NULL) users_can_manage_federations = models.BooleanField( - verbose_name=_('users can manage federation'), - default=True, - blank=True, - db_index=True) + verbose_name=_('users can manage federation'), + default=True, + blank=True, + db_index=True) objects = managers.GetByLibertyProviderManager() @@ -425,17 +468,20 @@ class LibertyServiceProvider(models.Model): LIBERTY_SESSION_DUMP_KIND_SP = 0 LIBERTY_SESSION_DUMP_KIND_IDP = 1 -LIBERTY_SESSION_DUMP_KIND = { LIBERTY_SESSION_DUMP_KIND_SP: 'sp', - LIBERTY_SESSION_DUMP_KIND_IDP: 'idp' } +LIBERTY_SESSION_DUMP_KIND = { + LIBERTY_SESSION_DUMP_KIND_SP: 'sp', + LIBERTY_SESSION_DUMP_KIND_IDP: 'idp', +} + class LibertySessionDump(models.Model): '''Store lasso session object dump. Should be replaced in the future by direct references to known assertions through the LibertySession object''' - django_session_key = models.CharField(max_length = 128) - session_dump = models.TextField(blank = True) - kind = models.IntegerField(choices = LIBERTY_SESSION_DUMP_KIND.items()) + django_session_key = models.CharField(max_length=128) + session_dump = models.TextField(blank=True) + kind = models.IntegerField(choices=LIBERTY_SESSION_DUMP_KIND.items()) objects = managers.SessionLinkedManager() @@ -444,12 +490,13 @@ class LibertySessionDump(models.Model): verbose_name_plural = _('SAML session dumps') unique_together = (('django_session_key', 'kind'),) + class LibertyArtifact(models.Model): """Store an artifact and the associated XML content""" creation = models.DateTimeField(auto_now_add=True) - artifact = models.CharField(max_length = 128, primary_key = True) + artifact = models.CharField(max_length=128, primary_key=True) content = models.TextField() - provider_id = models.CharField(max_length = 256) + provider_id = models.CharField(max_length=256) objects = managers.LibertyArtifactManager() @@ -457,31 +504,33 @@ class LibertyArtifact(models.Model): verbose_name = _('SAML artifact') verbose_name_plural = _('SAML artifacts') + def nameid2kwargs(name_id): return { 'name_id_qualifier': name_id.nameQualifier, 'name_id_sp_name_qualifier': name_id.spNameQualifier, 'name_id_content': name_id.content, - 'name_id_format': name_id.format } + 'name_id_format': name_id.format, + } # XXX: for retrocompatibility federation_delete = managers.federation_delete + @six.python_2_unicode_compatible class LibertyFederation(models.Model): """Store a federation, i.e. an identifier shared with another provider, be it IdP or SP""" - user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, - on_delete=models.SET_NULL) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL) sp = models.ForeignKey('LibertyServiceProvider', null=True, blank=True) - name_id_format = models.CharField(max_length = 100, - verbose_name = "NameIDFormat", blank=True, null=True) - name_id_content = models.CharField(max_length = 100, - verbose_name = "NameID") - name_id_qualifier = models.CharField(max_length = 256, - verbose_name = "NameQualifier", blank=True, null=True) - name_id_sp_name_qualifier = models.CharField(max_length = 256, - verbose_name = "SPNameQualifier", blank=True, null=True) + name_id_format = models.CharField(max_length=100, verbose_name="NameIDFormat", blank=True, null=True) + name_id_content = models.CharField(max_length=100, verbose_name="NameID") + name_id_qualifier = models.CharField(max_length=256, verbose_name="NameQualifier", blank=True, null=True) + name_id_sp_name_qualifier = models.CharField(max_length=256, verbose_name="SPNameQualifier", blank=True, null=True) termination_notified = models.BooleanField(blank=True, default=False) creation = models.DateTimeField(auto_now_add=True) last_modification = models.DateTimeField(auto_now=True) @@ -507,15 +556,13 @@ class LibertyFederation(models.Model): key += (None,) return key - def is_unique(self, for_format=True): '''Return whether a federation already exist for this user and this provider. By default the check is made by name_id_format, if you want to check whatever the format, set for_format to False. ''' - qs = LibertyFederation.objects.exclude(id=self.id) \ - .filter(user=self.user, idp=self.idp, sp=self.sp) + qs = LibertyFederation.objects.exclude(id=self.id).filter(user=self.user, idp=self.idp, sp=self.sp) if for_format: qs = qs.filter(name_id_format=self.name_id_format) return not qs.exists() @@ -531,19 +578,14 @@ class LibertyFederation(models.Model): @six.python_2_unicode_compatible class LibertySession(models.Model): """Store the link between a Django session and a SAML session""" - django_session_key = models.CharField(max_length = 128) - session_index = models.CharField(max_length = 80) - provider_id = models.CharField(max_length = 256) - federation = models.ForeignKey(LibertyFederation, blank=True, - null = True) - name_id_qualifier = models.CharField(max_length = 256, - verbose_name = _("Qualifier"), null = True) - name_id_format = models.CharField(max_length = 100, - verbose_name = _("NameIDFormat"), null = True) - name_id_content = models.CharField(max_length = 100, - verbose_name = _("NameID")) - name_id_sp_name_qualifier = models.CharField(max_length = 256, - verbose_name = _("SPNameQualifier"), null = True) + django_session_key = models.CharField(max_length=128) + session_index = models.CharField(max_length=80) + provider_id = models.CharField(max_length=256) + federation = models.ForeignKey(LibertyFederation, blank=True, null=True) + name_id_qualifier = models.CharField(max_length=256, verbose_name=_("Qualifier"), null=True) + name_id_format = models.CharField(max_length=100, verbose_name=_("NameIDFormat"), null=True) + name_id_content = models.CharField(max_length=100, verbose_name=_("NameID")) + name_id_sp_name_qualifier = models.CharField(max_length=256, verbose_name=_("SPNameQualifier"), null=True) creation = models.DateTimeField(auto_now_add=True) objects = managers.LibertySessionManager() @@ -567,15 +609,13 @@ class LibertySession(models.Model): return LibertySession.objects.none() kwargs = nameid2kwargs(name_id) name_id_qualifier = kwargs['name_id_qualifier'] - qs = LibertySession.objects.filter(provider_id=provider_id, - session_index__in=session_indexes) + qs = LibertySession.objects.filter(provider_id=provider_id, session_index__in=session_indexes) if name_id_qualifier and name_id_qualifier != issuer_id: qs = qs.filter(**kwargs) else: kwargs.pop('name_id_qualifier') - qs = qs.filter(**kwargs) \ - .filter(Q(name_id_qualifier__isnull=True)|Q(name_id_qualifier=issuer_id)) - qs = qs.filter(Q(name_id_sp_name_qualifier__isnull=True)|Q(name_id_sp_name_qualifier=provider_id)) + qs = qs.filter(**kwargs).filter(Q(name_id_qualifier__isnull=True) | Q(name_id_qualifier=issuer_id)) + qs = qs.filter(Q(name_id_sp_name_qualifier__isnull=True) | Q(name_id_sp_name_qualifier=provider_id)) return qs def __str__(self): @@ -585,6 +625,7 @@ class LibertySession(models.Model): verbose_name = _("SAML session") verbose_name_plural = _("SAML sessions") + @six.python_2_unicode_compatible class KeyValue(models.Model): key = models.CharField(max_length=128, primary_key=True) @@ -600,6 +641,7 @@ class KeyValue(models.Model): verbose_name = _("key value association") verbose_name_plural = _("key value associations") + def save_key_values(key, *values): # never update an existing key, key are nonces kv, created = KeyValue.objects.get_or_create(key=key, defaults={'value': values}) @@ -607,6 +649,7 @@ def save_key_values(key, *values): kv.value = values kv.save() + def get_and_delete_key_values(key): try: kv = KeyValue.objects.get(key=key) diff --git a/src/authentic2/saml/saml11utils.py b/src/authentic2/saml/saml11utils.py deleted file mode 100644 index fd05c160..00000000 --- a/src/authentic2/saml/saml11utils.py +++ /dev/null @@ -1,210 +0,0 @@ -from __future__ import print_function - -import xml.etree.ElementTree as etree -from authentic2.compat_lasso import lasso -from authentic2.saml import x509utils -from authentic2.saml.saml2utils import bool2xs, NamespacedTreeBuilder, keyinfo - -class Saml11Metadata(object): - ENTITY_DESCRIPTOR = 'EntityDescriptor' - SP_SSO_DESCRIPTOR = 'SPDescriptor' - IDP_SSO_DESCRIPTOR = 'IDPDescriptor' - PROTOCOL_SUPPORT_ENUMERATION = 'protocolSupportEnumeration' - SOAP_ENDPOINT = 'SoapEndpoint' - PROVIDER_ID = 'providerID' - VALID_UNTIL = 'validUntil' - CACHE_DURATION = 'cacheDuration' - ENCRYPTION_METHOD = 'EncryptionMethod' - KEY_SIZE = 'KeySize' - KEY_DESCRIPTOR = 'KeyDescriptor' - SERVICE_URL = "ServiceURL" - SERVICE_RETURN_URL = "ServiceReturnURL" - USE = 'use' - PROTOCOL_PROFILE = 'ProtocolProfile' - FEDERATION_TERMINATION_NOTIFICATION_PROTOCOL_PROFILE = \ - 'FederationTerminationNotificationProtocolProfile' - AUTHN_REQUESTS_SIGNED = 'AuthnRequestsSigned' - # Service prefixes - SINGLE_LOGOUT = 'SingleLogout' - FEDERATION_TERMINATION = 'FederationTermination' - REGISTER_NAME_IDENTIFIER = 'RegisterNameIdentifier' - # SP Services prefixes - ASSERTION_CONSUMER = 'AssertionConsumer' - # IDP Services prefixes - SINGLE_SIGN_ON = 'SingleSignOn' - AUTHN = 'Authn' - - sso_services = ( SOAP_ENDPOINT, SINGLE_LOGOUT, FEDERATION_TERMINATION, - REGISTER_NAME_IDENTIFIER ) - idp_services = ( SINGLE_SIGN_ON, AUTHN) - sp_services = ( ASSERTION_CONSUMER, AUTHN_REQUESTS_SIGNED) - - def __init__(self, entity_id, url_prefix = '', valid_until = None, - cache_duration = None, protocol_support_enumeration = []): - '''Initialize a new generator for a metadata file. - - Entity id is the name of the provider - ''' - self.entity_id = entity_id - self.url_prefix = url_prefix - self.role_descriptors = {} - self.valid_until = valid_until - self.cache_duration = cache_duration - self.tb = NamespacedTreeBuilder() - self.tb.pushNamespace(lasso.METADATA_HREF) - if not protocol_support_enumeration: - raise TypeError('Protocol Support Enumeration is mandatory') - self.protocol_support_enumeration = protocol_support_enumeration - - def add_role_descriptor(self, role, map, options): - '''Add a role descriptor, map is a sequence of tuples formatted as - - (endpoint_type, (bindings, ..) , url [, return_url])''' - if not self.SOAP_ENDPOINT in map: - raise TypeError('SoapEndpoint is mandatory in SAML 1.1 role descriptors') - self.role_descriptors[role] = (map, options) - - def add_sp_descriptor(self, map, options): - if not self.ASSERTION_CONSUMER in map: - raise TypeError('AssertionConsumer is mandarotyr in SAML 1.1 SP role descriptors') - for row in map: - if row not in self.sp_services + self.sso_services: - raise TypeError(row) - self.add_role_descriptor('sp', map, options) - - def add_idp_descriptor(self, map, options): - if not self.SINGLE_SIGN_ON in map: - raise TypeError('SingleSignOn is mandarotyr in SAML 1.1 SP role descriptors') - for row in map: - if row not in self.idp_services + self.sso_services: - raise TypeError(row) - self.add_role_descriptor('idp', map, options) - - def add_keyinfo(self, key, use, encryption_method = None, key_size = None): - attrib = {} - if use: - attrib = { self.USE: use } - self.tb.start(self.KEY_DESCRIPTOR, attrib) - if encryption_method: - self.tb.simple_content(self.ENCRYPTION_METHOD, encryption_method) - if key_size: - self.tb.simple_content(self.KEY_SIZE, str(key_size)) - keyinfo(self.tb, key) - self.tb.end(self.KEY_DESCRIPTOR) - - def add_service_url(self, name, map): - service = map.get(name) - if service: - service_urls = service[0] - self.tb.simple_content(name + self.SERVICE_URL, - self.url_prefix + service_urls[0]) - if len(service_urls) == 2: - self.tb.simple_content(name + self.SERVICE_RETURN_URL, - self.url_prefix + service_urls[1]) - - def add_profile(self, name, map, tag = None): - if not tag: - tag = name + self.PROTOCOL_PROFILE - service = map.get(name) - if service: - service_profiles = service[1] - for profile in service_profiles: - self.tb.simple_content(tag, profile) - - def generate_sso_descriptor(self, name, map, options): - attrib = {} - - if options.get('valid_until'): - attrib[self.VALID_UNTIL] = options['valid_until'] - if options.get('cached_duration'): - attrib[self.CACHE_DURATION] = options['cache_duration'] - attrib[self.PROTOCOL_SUPPORT_ENUMERATION] = options[self.PROTOCOL_SUPPORT_ENUMERATION] - self.tb.start(name, attrib) - # Add KeyDescriptor(s) - if options.get('signing_key'): - self.add_keyinfo(options['signing_key'], 'signing',) - if options.get('encryption_key'): - self.add_keyinfo(options['encryption_key'], 'encryption', - encryption_method = options.get('encryption_method'), - key_size = options.get('key_size')) - if options.get('key'): - self.add_keyinfo(options['encryption_key'], 'signing encryption', - encryption_method = options.get('encryption_method'), - key_size = options.get('key_size')) - # Add SOAP Endpoint - self.tb.simple_content(self.SOAP_ENDPOINT, - self.url_prefix + map[self.SOAP_ENDPOINT]) - # Add SingleLogoutService - self.add_service_url(self.SINGLE_LOGOUT, map) - # Add FederationTerminationService URL - self.add_service_url(self.FEDERATION_TERMINATION, map) - self.add_profile(self.FEDERATION_TERMINATION, map, - tag = self.FEDERATION_TERMINATION_NOTIFICATION_PROTOCOL_PROFILE) - # Add SingleLogoutProtocolProfile - self.add_profile(self.SINGLE_LOGOUT, map) - # Add RegisterNameIdentifier - self.add_profile(self.REGISTER_NAME_IDENTIFIER, map) - self.add_service_url(self.REGISTER_NAME_IDENTIFIER, map) - - def generate_idp_descriptor(self, map, options): - self.generate_sso_descriptor(self.IDP_SSO_DESCRIPTOR, map, options) - # Add SingleSignOnServiceURL - self.add_service_url(self.SINGLE_SIGN_ON, map) - self.add_profile(self.SINGLE_SIGN_ON, map) - # Add AuthnServiceURL - self.add_service_url(self.AUTHN, map) - self.tb.end(self.IDP_SSO_DESCRIPTOR) - - def generate_sp_descriptor(self, map, options): - self.generate_sso_descriptor(self.SP_SSO_DESCRIPTOR, map, options) - # Add AssertionConsumerServiceURL - self.add_service_url(self.ASSERTION_CONSUMER) - self.simple_content(self.AUTHN_REQUESTS_SIGNED, - bool2xs(options.get(self.AUTHN_REQUESTS_SIGNED, False))) - self.tb.end(self.SP_SSO_DESCRIPTOR) - - def root_element(self): - attrib = { self.PROVIDER_ID : self.entity_id} - if self.cache_duration: - attrib['cacheDuration'] = self.cache_duration - if self.valid_until: - attrib['validUntil'] = self.valid_until - self.entity_descriptor = self.tb.start(self.ENTITY_DESCRIPTOR, attrib) - # Generate sso descriptor - attrib = { self.PROTOCOL_SUPPORT_ENUMERATION: ' '.join(self.protocol_support_enumeration) } - if self.role_descriptors.get('idp'): - map, options = self.role_descriptors['idp'] - options.update(attrib) - self.generate_idp_descriptor(map, options) - if self.role_descriptors.get('sp'): - map, options = self.role_descriptors['sp'] - options.update(attrib) - self.generate_idp_sso_descriptor(map, options) - self.tb.end(self.ENTITY_DESCRIPTOR) - return self.tb.close() - - def __str__(self): - return '\n' + etree.tostring(self.root_element()) - -if __name__ == '__main__': - pkey, _ = x509utils.generate_rsa_keypair() - meta = Saml11Metadata('http://example.com/saml', - 'http://example.com/saml/prefix/', - protocol_support_enumeration = [ lasso.LIB_HREF ]) - sso_protocol_profiles = [ - lasso.LIB_PROTOCOL_PROFILE_BRWS_ART, - lasso.LIB_PROTOCOL_PROFILE_BRWS_POST, - lasso.LIB_PROTOCOL_PROFILE_BRWS_LECP ] - slo_protocol_profiles = [ - lasso.LIB_PROTOCOL_PROFILE_SLO_SP_HTTP, - lasso.LIB_PROTOCOL_PROFILE_SLO_SP_SOAP, - lasso.LIB_PROTOCOL_PROFILE_SLO_IDP_HTTP, - lasso.LIB_PROTOCOL_PROFILE_SLO_IDP_SOAP ] - options = { 'signing_key': pkey } - meta.add_idp_descriptor({ - 'SoapEndpoint': 'soap', - 'SingleLogout': (('slo', 'sloReturn'), slo_protocol_profiles), - 'SingleSignOn': (('sso',), sso_protocol_profiles), - }, options) - root = meta.root_element() - print(etree.tostring(root)) diff --git a/src/authentic2/saml/saml2utils.py b/src/authentic2/saml/saml2utils.py index 69fb2c27..5d98bac3 100644 --- a/src/authentic2/saml/saml2utils.py +++ b/src/authentic2/saml/saml2utils.py @@ -1,3 +1,19 @@ +# 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 . + from __future__ import print_function import xml.etree.ElementTree as etree @@ -22,9 +38,10 @@ def filter_attribute_private_key(message): def filter_element_private_key(message): if isinstance(message, six.string_types): - return re.sub(r'(-----BEGIN RSA PRIVATE KEY-----)' - '([&#;\w/+=\s])+' - '(-----END RSA PRIVATE KEY-----)', + return re.sub( + r'(-----BEGIN RSA PRIVATE KEY-----)' + r'([&#;\w/+=\s])+' + r'(-----END RSA PRIVATE KEY-----)', '', message) else: return message @@ -38,12 +55,14 @@ def bool2xs(boolean): return 'false' raise TypeError() + def int_to_b64(i): h = hex(i)[2:].strip('L') if len(h) % 2 == 1: h = '0' + h return base64.b64encode(binascii.unhexlify(h)) + def keyinfo(tb, key): tb.pushNamespace(lasso.DS_HREF) tb.start('KeyInfo', {}) @@ -68,6 +87,7 @@ def keyinfo(tb, key): tb.end('KeyInfo') tb.popNamespace() + class NamespacedTreeBuilder(etree.TreeBuilder): def __init__(self, *args, **kwargs): self.__old_ns = [] @@ -92,7 +112,7 @@ class NamespacedTreeBuilder(etree.TreeBuilder): self.data(data) self.end() - def end(self, tag = None): + def end(self, tag=None): if tag: self.__opened.pop() tag = '{%s}%s' % (self.__ns, tag) @@ -100,6 +120,7 @@ class NamespacedTreeBuilder(etree.TreeBuilder): tag = self.__opened.pop() return etree.TreeBuilder.end(self, tag) + class Saml2Metadata(object): ENTITY_DESCRIPTOR = 'EntityDescriptor' SP_SSO_DESCRIPTOR = 'SPSSODescriptor' @@ -118,16 +139,12 @@ class Saml2Metadata(object): DISCOVERY_NS = 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol' DISCOVERY_BINDING = 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol' - sso_services = ( ARTIFACT_RESOLUTION_SERVICE, SINGLE_LOGOUT_SERVICE, - MANAGE_NAME_ID_SERVICE ) - idp_services = ( SINGLE_SIGN_ON_SERVICE, NAME_ID_MAPPING_SERVICE, - ASSERTION_ID_REQUEST_SERVICE ) - sp_services = ( ASSERTION_CONSUMER_SERVICE, ) - indexed_endpoints = ( ARTIFACT_RESOLUTION_SERVICE, - ASSERTION_CONSUMER_SERVICE ) + sso_services = (ARTIFACT_RESOLUTION_SERVICE, SINGLE_LOGOUT_SERVICE, MANAGE_NAME_ID_SERVICE) + idp_services = (SINGLE_SIGN_ON_SERVICE, NAME_ID_MAPPING_SERVICE, ASSERTION_ID_REQUEST_SERVICE) + sp_services = (ASSERTION_CONSUMER_SERVICE,) + indexed_endpoints = (ARTIFACT_RESOLUTION_SERVICE, ASSERTION_CONSUMER_SERVICE) - def __init__(self, entity_id, url_prefix = '', valid_until = None, - cache_duration = None): + def __init__(self, entity_id, url_prefix='', valid_until=None, cache_duration=None): '''Initialize a new generator for a metadata file. Entity id is the name of the provider @@ -184,17 +201,19 @@ class Saml2Metadata(object): self.add_keyinfo(options['key'], None) if 'disco' in options: self.add_disco_extension(options['disco']) - endpoint_idx = collections.defaultdict(lambda:0) + endpoint_idx = collections.defaultdict(lambda: 0) for service in listing: - selected = [ row for row in map if row[0] == service ] + selected = [row for row in map if row[0] == service] for row in selected: if isinstance(row[1], str): - bindings = [ row[1] ] + bindings = [row[1]] else: bindings = row[1] for binding in bindings: - attribs = { 'Binding' : binding, - 'Location': self.url_prefix + row[2] } + attribs = { + 'Binding': binding, + 'Location': self.url_prefix + row[2] + } if len(row) == 4: attribs['ResponseLocation'] = self.url_prefix + row[3] if service in self.indexed_endpoints: @@ -211,13 +230,13 @@ class Saml2Metadata(object): def add_keyinfo(self, key, use): attrib = {} if use: - attrib = { 'use': use } + attrib = {'use': use} self.tb.start(self.KEY_DESCRIPTOR, attrib) keyinfo(self.tb, key) self.tb.end(self.KEY_DESCRIPTOR) def root_element(self): - attrib = { 'entityID' : self.entity_id} + attrib = {'entityID': self.entity_id} if self.cache_duration: attrib['cacheDuration'] = self.cache_duration if self.valid_until: @@ -225,7 +244,7 @@ class Saml2Metadata(object): self.entity_descriptor = self.tb.start(self.ENTITY_DESCRIPTOR, attrib) # Generate sso descriptor - attrib = { self.PROTOCOL_SUPPORT_ENUMERATION: lasso.SAML2_PROTOCOL_HREF } + attrib = {self.PROTOCOL_SUPPORT_ENUMERATION: lasso.SAML2_PROTOCOL_HREF} if self.role_descriptors.get('sp'): map, options = self.role_descriptors['sp'] self.sp_descriptor = self.tb.start(self.SP_SSO_DESCRIPTOR, attrib) @@ -246,9 +265,11 @@ class Saml2Metadata(object): self.tb.pushNamespace(self.DISCOVERY_NS) index = 0 for url in disco_return_url: - attrib = {'Binding': self.DISCOVERY_BINDING, + attrib = { + 'Binding': self.DISCOVERY_BINDING, 'Location': self.url_prefix + url, - 'index': str(index)} + 'index': str(index) + } self.tb.start(self.DISCOVERY_RESPONSE, attrib) self.tb.end(self.DISCOVERY_RESPONSE) index += 1 @@ -258,6 +279,7 @@ class Saml2Metadata(object): def __str__(self): return '\n' + etree.tostring(self.root_element()) + def iso8601_to_datetime(date_string): '''Convert a string formatted as an ISO8601 date into a time_t value. @@ -265,7 +287,7 @@ def iso8601_to_datetime(date_string): m = re.match(r'(\d+-\d+-\d+T\d+:\d+:\d+)(?:\.\d+)?Z$', date_string) if not m: raise ValueError('Invalid ISO8601 date') - tm = time.strptime(m.group(1)+'Z', "%Y-%m-%dT%H:%M:%SZ") + tm = time.strptime(m.group(1) + 'Z', "%Y-%m-%dT%H:%M:%SZ") return datetime.datetime.fromtimestamp(time.mktime(tm)) def authnresponse_checking(login, subject_confirmation, logger, saml_request_id=None): @@ -282,7 +304,7 @@ def authnresponse_checking(login, subject_confirmation, logger, saml_request_id= try: irt = assertion.subject. \ subjectConfirmation.subjectConfirmationData.inResponseTo - except: + except Exception: pass logger.debug('inResponseTo: %s' % irt) @@ -294,12 +316,10 @@ def authnresponse_checking(login, subject_confirmation, logger, saml_request_id= try: if assertion.subject.subjectConfirmation.method != \ 'urn:oasis:names:tc:SAML:2.0:cm:bearer': - logger.error('Unknown \ - SubjectConfirmation Method') + logger.error('Unknown SubjectConfirmation Method') return False - except: - logger.error('Error checking \ - SubjectConfirmation Method') + except Exception: + logger.error('Error checking SubjectConfirmation Method') return False logger.debug('subjectConfirmation method known') @@ -308,17 +328,14 @@ def authnresponse_checking(login, subject_confirmation, logger, saml_request_id= if assertion.subject. \ subjectConfirmation.subjectConfirmationData.recipient != \ subject_confirmation: - logger.error('SubjectConfirmation \ - Recipient Mismatch, %s is not %s' % (assertion.subject. \ - subjectConfirmation.subjectConfirmationData.recipient, - subject_confirmation)) + logger.error('SubjectConfirmation Recipient Mismatch, %s is not %s', + assertion.subject.subjectConfirmation.subjectConfirmationData.recipient, + subject_confirmation) return False - except: - logger.error('Error checking \ - SubjectConfirmation Recipient') + except Exception: + logger.error('Error checking SubjectConfirmation Recipient') return False - logger.debug('\ - the url is the same as in the assertion') + logger.debug('the url is the same as in the assertion') # Check: AudienceRestriction try: @@ -331,7 +348,7 @@ def authnresponse_checking(login, subject_confirmation, logger, saml_request_id= if not audience_ok: logger.error('Incorrect AudienceRestriction') return False - except: + except Exception: logger.error('Error checking AudienceRestriction') return False logger.debug('audience restriction respected') @@ -341,7 +358,7 @@ def authnresponse_checking(login, subject_confirmation, logger, saml_request_id= try: not_before = assertion.subject. \ subjectConfirmation.subjectConfirmationData.notBefore - except: + except Exception: logger.error('missing subjectConfirmationData') return False @@ -363,22 +380,22 @@ def authnresponse_checking(login, subject_confirmation, logger, saml_request_id= if not_before and now < iso8601_to_datetime(not_before): logger.error('Assertion received too early') return False - except: + except Exception: logger.error('invalid notBefore value ' + not_before) return False try: if not_on_or_after and now > iso8601_to_datetime(not_on_or_after): logger.error('Assertion expired') return False - except: + except Exception: logger.error('invalid notOnOrAfter value') return False logger.debug('assertion validity timeslice respected \ %s <= %s < %s ' % (not_before, str(now), not_on_or_after)) - return True + def get_attributes_from_assertion(assertion, logger): attributes = dict() if not assertion: @@ -390,7 +407,7 @@ def get_attributes_from_assertion(assertion, logger): nickname = None try: name = attribute.name.decode('ascii') - except: + except Exception: logger.warning('get_attributes_from_assertion: error decoding name of \ attribute %s' % attribute.dump()) else: @@ -412,8 +429,7 @@ def get_attributes_from_assertion(assertion, logger): for value in values: content = [any.exportToXml() for any in value.any] content = ''.join(content) - attributes[(name, format)].append(content.\ - decode('utf8')) + attributes[(name, format)].append(content.decode('utf8')) except Exception as e: message = 'get_attributes_from_assertion: value of an \ attribute failed to decode as ascii: %s due to %s' @@ -426,17 +442,19 @@ def get_attributes_from_assertion(assertion, logger): if __name__ == '__main__': pkey, _ = x509utils.generate_rsa_keypair() meta = Saml2Metadata('http://example.com/saml', 'http://example.com/saml/prefix/') - bindings2 = [ lasso.SAML2_METADATA_BINDING_SOAP, - lasso.SAML2_METADATA_BINDING_REDIRECT, - lasso.SAML2_METADATA_BINDING_POST ] - options = { 'signing_key': pkey } + bindings2 = [ + lasso.SAML2_METADATA_BINDING_SOAP, + lasso.SAML2_METADATA_BINDING_REDIRECT, + lasso.SAML2_METADATA_BINDING_POST, + ] + options = {'signing_key': pkey} meta.add_sp_descriptor(( ('SingleLogoutService', lasso.SAML2_METADATA_BINDING_SOAP, 'logout', 'logoutReturn' ), ('ManageNameIDService', bindings2, 'manageNameID', 'manageNameIDReturn' ), ('AssertionConsumerService', - [ lasso.SAML2_METADATA_BINDING_POST ], 'acs'),), + [lasso.SAML2_METADATA_BINDING_POST ], 'acs'),), options) root = meta.root_element() print(etree.tostring(root)) diff --git a/src/authentic2/saml/shibboleth/afp_parser.py b/src/authentic2/saml/shibboleth/afp_parser.py index 6c49468b..64d2db7e 100644 --- a/src/authentic2/saml/shibboleth/afp_parser.py +++ b/src/authentic2/saml/shibboleth/afp_parser.py @@ -1,9 +1,26 @@ +# 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 . + from __future__ import print_function import xml.etree.ElementTree as ET from authentic2.saml.shibboleth.utils import FancyTreeBuilder + class NS(object): AFP = 'urn:mace:shibboleth:2.0:afp' BASIC = 'urn:mace:shibboleth:2.0:afp:mf:basic' @@ -26,11 +43,13 @@ class NS(object): ATTRIBUTE_ID = 'attributeID' ID = 'id' + def parse_attribute_filters_file(path): tree = ET.parse(path, FancyTreeBuilder(target=ET.TreeBuilder())) root = tree.getroot() return parse_attribute_filter_et(root) + def fixqname(element, qname): prefix, local = qname.split(":") try: @@ -38,6 +57,7 @@ def fixqname(element, qname): except KeyError: raise SyntaxError("unknown namespace prefix (%s)" % prefix) + def parse_attribute_filter_et(root): assert root.tag == NS.AF_POLICY_GROUP d = {} diff --git a/src/authentic2/saml/shibboleth/utils.py b/src/authentic2/saml/shibboleth/utils.py index c77c11a8..9100fbd7 100644 --- a/src/authentic2/saml/shibboleth/utils.py +++ b/src/authentic2/saml/shibboleth/utils.py @@ -1,4 +1,19 @@ -import xml.etree.ElementTree as ET +# 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 . + from xml.etree.ElementTree import XMLTreeBuilder @@ -10,7 +25,6 @@ class FancyTreeBuilder(XMLTreeBuilder): self._namespaces = {} self._parser.StartNamespaceDeclHandler = self._start_ns - def _start(self, *args): elem = super(FancyTreeBuilder, self)._start(*args) elem.namespaces = self._namespaces.copy() diff --git a/src/authentic2/saml/utils.py b/src/authentic2/saml/utils.py index be0a6872..c960e789 100644 --- a/src/authentic2/saml/utils.py +++ b/src/authentic2/saml/utils.py @@ -1,3 +1,19 @@ +# 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 . + from authentic2.decorators import GlobalCache diff --git a/src/authentic2/saml/x509utils.py b/src/authentic2/saml/x509utils.py index a3d28644..291b3355 100644 --- a/src/authentic2/saml/x509utils.py +++ b/src/authentic2/saml/x509utils.py @@ -1,3 +1,19 @@ +# 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 base64 import binascii import tempfile @@ -8,6 +24,7 @@ import six _openssl = 'openssl' + def decapsulate_pem_file(file_or_string): '''Remove PEM header lines''' if not isinstance(file_or_string, six.string_types): @@ -17,8 +34,9 @@ def decapsulate_pem_file(file_or_string): i = content.find('--BEGIN') j = content.find('\n', i) k = content.find('--END', j) - l = content.rfind('\n', 0, k) - return content[j+1:l] + l = content.rfind('\n', 0, k) # noqa: E741 + return content[j + 1:l] + def _call_openssl(args): '''Use subprocees to spawn an openssl process @@ -26,24 +44,22 @@ def _call_openssl(args): Return a tuple made of the return code and the stdout output ''' try: - process = subprocess.Popen(args=[_openssl]+args,stdout=subprocess.PIPE,stderr=subprocess.STDOUT) + process = subprocess.Popen(args=[_openssl] + args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) output = process.communicate()[0] return process.returncode, output except OSError: return 1, None -def _protect_file(fd,filepath): +def _protect_file(fd, filepath): '''Make a file targeted by a file descriptor readable only by the current user It's needed to be sure nobody can read the private key file we manage. ''' - if hasattr(os, 'fchmod'): - os.fchmod(fd, stat.S_IRUSR | stat.S_IWUSR) - else: # handle python <2.6 - os.chmod(filepath, stat.S_IRUSR | stat.S_IWUSR) + os.fchmod(fd, stat.S_IRUSR | stat.S_IWUSR) -def check_key_pair_consistency(publickey=None,privatekey=None): + +def check_key_pair_consistency(publickey=None, privatekey=None): '''Check if two PEM key pair whether they are publickey or certificate, are well formed and related. ''' @@ -53,21 +69,21 @@ def check_key_pair_consistency(publickey=None,privatekey=None): publickey_file_fd, publickey_fn = tempfile.mkstemp() _protect_file(privatekey_file_fd, privatekey_fn) _protect_file(publickey_file_fd, publickey_fn) - os.fdopen(privatekey_file_fd,'w').write(privatekey) - os.fdopen(publickey_file_fd,'w').write(publickey) + os.fdopen(privatekey_file_fd, 'w').write(privatekey) + os.fdopen(publickey_file_fd, 'w').write(publickey) if 'BEGIN CERTIFICATE' in publickey: - rc1, modulus1 = _call_openssl(['x509', '-in', publickey_fn,'-noout','-modulus']) + rc1, modulus1 = _call_openssl(['x509', '-in', publickey_fn, '-noout', '-modulus']) else: - rc1, modulus1 = _call_openssl(['rsa', '-pubin', '-in', publickey_fn,'-noout','-modulus']) + rc1, modulus1 = _call_openssl(['rsa', '-pubin', '-in', publickey_fn, '-noout', '-modulus']) if rc1 != 0: - rc1, modulus1 = _call_openssl(['dsa', '-pubin', '-in', publickey_fn,'-noout','-modulus']) + rc1, modulus1 = _call_openssl(['dsa', '-pubin', '-in', publickey_fn, '-noout', '-modulus']) if rc1 != 0: return False - rc2, modulus2 = _call_openssl(['rsa', '-in', privatekey_fn,'-noout','-modulus']) + rc2, modulus2 = _call_openssl(['rsa', '-in', privatekey_fn, '-noout', '-modulus']) if rc2 != 0: - rc2, modulus2 = _call_openssl(['dsa', '-in', privatekey_fn,'-noout','-modulus']) + rc2, modulus2 = _call_openssl(['dsa', '-in', privatekey_fn, '-noout', '-modulus']) if rc1 == 0 and rc2 == 0 and modulus1 == modulus2: return True @@ -78,6 +94,7 @@ def check_key_pair_consistency(publickey=None,privatekey=None): os.unlink(publickey_fn) return None + def generate_rsa_keypair(numbits=1024): '''Generate simple RSA public and private key files ''' @@ -86,8 +103,8 @@ def generate_rsa_keypair(numbits=1024): publickey_file_fd, publickey_fn = tempfile.mkstemp() _protect_file(privatekey_file_fd, privatekey_fn) _protect_file(publickey_file_fd, publickey_fn) - rc1, _ = _call_openssl(['genrsa','-out', privatekey_fn,'-passout', 'pass:',str(numbits)]) - rc2, _ = _call_openssl(['rsa','-in', privatekey_fn,'-pubout','-out', publickey_fn]) + rc1, _ = _call_openssl(['genrsa', '-out', privatekey_fn, '-passout', 'pass:', str(numbits)]) + rc2, _ = _call_openssl(['rsa', '-in', privatekey_fn, '-pubout', '-out', publickey_fn]) if rc1 != 0 or rc2 != 0: raise Exception('Failed to generate a key') return (os.fdopen(publickey_file_fd).read(), os.fdopen(privatekey_file_fd).read()) @@ -95,56 +112,60 @@ def generate_rsa_keypair(numbits=1024): os.unlink(privatekey_fn) os.unlink(publickey_fn) + def get_rsa_public_key_modulus(publickey): try: publickey_file_fd, publickey_fn = tempfile.mkstemp() - os.fdopen(publickey_file_fd,'w').write(publickey) + os.fdopen(publickey_file_fd, 'w').write(publickey) if 'BEGIN PUBLIC' in publickey: - rc, modulus = _call_openssl(['rsa', '-pubin', '-in', publickey_fn,'-noout','-modulus']) + rc, modulus = _call_openssl(['rsa', '-pubin', '-in', publickey_fn, '-noout', '-modulus']) elif 'BEGIN RSA PRIVATE KEY' in publickey: rc, modulus = _call_openssl(['rsa', '-in', publickey_fn, '-noout', '-modulus']) elif 'BEGIN CERTIFICATE' in publickey: - rc, modulus = _call_openssl(['x509', '-in', publickey_fn,'-noout','-modulus']) + rc, modulus = _call_openssl(['x509', '-in', publickey_fn, '-noout', '-modulus']) else: return None i = modulus.find('=') if rc == 0 and i: - return int(modulus[i+1:].strip(),16) + return int(modulus[i + 1:].strip(), 16) finally: os.unlink(publickey_fn) return None + def get_rsa_public_key_exponent(publickey): try: publickey_file_fd, publickey_fn = tempfile.mkstemp() - os.fdopen(publickey_file_fd,'w').write(publickey) + os.fdopen(publickey_file_fd, 'w').write(publickey) _exponent = 'Exponent: ' if 'BEGIN PUBLIC' in publickey: - rc, modulus = _call_openssl(['rsa', '-pubin', '-in', publickey_fn,'-noout','-text']) + rc, modulus = _call_openssl(['rsa', '-pubin', '-in', publickey_fn, '-noout', '-text']) elif 'BEGIN RSA PRIVATE' in publickey: rc, modulus = _call_openssl(['rsa', '-in', publickey_fn, '-noout', '-text']) _exponent = 'publicExponent: ' elif 'BEGIN CERTIFICATE' in publickey: - rc, modulus = _call_openssl(['x509', '-in', publickey_fn,'-noout','-text']) + rc, modulus = _call_openssl(['x509', '-in', publickey_fn, '-noout', '-text']) else: return None i = modulus.find(_exponent) j = modulus.find('(', i) if rc == 0 and i and j: - return int(modulus[i+len(_exponent):j].strip()) + return int(modulus[i + len(_exponent):j].strip()) finally: os.unlink(publickey_fn) return None + def can_generate_rsa_key_pair(): syspath = os.environ.get('PATH') if syspath: for base in syspath.split(':'): - if os.path.exists(os.path.join(base,'openssl')): + if os.path.exists(os.path.join(base, 'openssl')): return True else: return False + def get_xmldsig_rsa_key_value(publickey): def int_to_bin(i): h = hex(i)[2:].strip('L') @@ -154,7 +175,11 @@ def get_xmldsig_rsa_key_value(publickey): mod = get_rsa_public_key_modulus(publickey) exp = get_rsa_public_key_exponent(publickey) - return '\n\t%s\n\t%s\n' % (base64.b64encode(int_to_bin(mod)), base64.b64encode(int_to_bin(exp))) + return ( + '\n\t' + '%s\n\t' + '%s\n' % ( + base64.b64encode(int_to_bin(mod)), base64.b64encode(int_to_bin(exp)))) if __name__ == '__main__': @@ -200,5 +225,5 @@ pkkt86tIOLEtaNO97CcF/t+Un5QAh9MqLmQv5pwUDo4Lqo7qo1bAfyHjOlr5kdaP -----END RSA PRIVATE KEY-----''' assert(check_key_pair_consistency(cert, key)) assert(get_xmldsig_rsa_key_value(cert)) - assert(len(decapsulate_pem_file(key).splitlines()) == len(key.splitlines())-2) + assert(len(decapsulate_pem_file(key).splitlines()) == len(key.splitlines()) - 2) diff --git a/src/authentic2/serializers.py b/src/authentic2/serializers.py index 1fdcafc7..2b7251be 100644 --- a/src/authentic2/serializers.py +++ b/src/authentic2/serializers.py @@ -1,3 +1,19 @@ +# 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 json import sys @@ -31,6 +47,7 @@ class Serializer(JSONSerializer): self._current[vfield.name] = (ct.natural_key(), sub_obj.natural_key()) super(Serializer, self).end_object(obj) + def PreDeserializer(objects, **options): db = options.pop('using', DEFAULT_DB_ALIAS) @@ -39,7 +56,7 @@ def PreDeserializer(objects, **options): for vfield in Model._meta.virtual_fields: if not isinstance(vfield, GenericForeignKey): continue - if not vfield.name in d['fields']: + if vfield.name not in d['fields']: continue ct_natural_key, fk_natural_key = d['fields'][vfield.name] ct = ContentType.objects.get_by_natural_key(*ct_natural_key) @@ -49,6 +66,7 @@ def PreDeserializer(objects, **options): del d['fields'][vfield.name] yield d + def Deserializer(stream_or_string, **options): """ Deserialize a stream or string of JSON data. diff --git a/src/authentic2/settings.py b/src/authentic2/settings.py index 9ffe4927..210db386 100644 --- a/src/authentic2/settings.py +++ b/src/authentic2/settings.py @@ -1,3 +1,19 @@ +# 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 logging import logging.config # Load default from Django @@ -12,7 +28,8 @@ from . import plugins, logger CACHES = global_settings.CACHES BASE_DIR = os.path.dirname(__file__) -### Quick-start development settings - unsuitable for production + +# Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! @@ -36,9 +53,6 @@ DATABASES = { } } -### End of "Quick-start development settings" - - # Hey Entr'ouvert is in France !! TIME_ZONE = 'Europe/Paris' LANGUAGE_CODE = 'fr' @@ -91,7 +105,6 @@ DATABASES['default']['ATOMIC_REQUESTS'] = True MIDDLEWARE_CLASSES += ( 'authentic2.middleware.DisplayMessageBeforeRedirectMiddleware', - 'authentic2.idp.middleware.DebugMiddleware', 'authentic2.middleware.CollectIPMiddleware', 'authentic2.middleware.ViewRestrictionMiddleware', 'authentic2.middleware.OpenedSessionCookieMiddleware', @@ -103,7 +116,7 @@ ROOT_URLCONF = 'authentic2.urls' STATICFILES_FINDERS = list(global_settings.STATICFILES_FINDERS) + ['gadjo.finders.XStaticFinder'] -LOCALE_PATHS = ( os.path.join(BASE_DIR, 'locale'), ) +LOCALE_PATHS = (os.path.join(BASE_DIR, 'locale'), ) INSTALLED_APPS = ( 'django.contrib.staticfiles', @@ -144,8 +157,7 @@ AUTHENTICATION_BACKENDS = ( 'authentic2.backends.models_backend.DummyModelBackend', 'django_rbac.backends.DjangoRBACBackend', ) -AUTHENTICATION_BACKENDS = plugins.register_plugins_authentication_backends( - AUTHENTICATION_BACKENDS) +AUTHENTICATION_BACKENDS = plugins.register_plugins_authentication_backends(AUTHENTICATION_BACKENDS) CSRF_FAILURE_VIEW = 'authentic2.views.csrf_failure_view' @@ -186,13 +198,13 @@ IDP_BACKENDS = plugins.register_plugins_idp_backends(()) # Can be none, sp, idp or both PASSWORD_HASHERS = list(global_settings.PASSWORD_HASHERS) + [ - 'authentic2.hashers.Drupal7PasswordHasher', - 'authentic2.hashers.SHA256PasswordHasher', - 'authentic2.hashers.SSHA1PasswordHasher', - 'authentic2.hashers.SMD5PasswordHasher', - 'authentic2.hashers.SHA1OLDAPPasswordHasher', - 'authentic2.hashers.MD5OLDAPPasswordHasher', - 'authentic2.hashers.PloneSHA1PasswordHasher', + 'authentic2.hashers.Drupal7PasswordHasher', + 'authentic2.hashers.SHA256PasswordHasher', + 'authentic2.hashers.SSHA1PasswordHasher', + 'authentic2.hashers.SMD5PasswordHasher', + 'authentic2.hashers.SHA1OLDAPPasswordHasher', + 'authentic2.hashers.MD5OLDAPPasswordHasher', + 'authentic2.hashers.PloneSHA1PasswordHasher', ] # Admin tools @@ -202,7 +214,7 @@ ADMIN_TOOLS_MENU = 'authentic2.menu.CustomMenu' # Serialization module to support natural keys in generic foreign keys SERIALIZATION_MODULES = { - 'json': 'authentic2.serializers', + 'json': 'authentic2.serializers', } LOGGING_CONFIG = None @@ -211,10 +223,10 @@ LOGGING = { 'disable_existing_loggers': True, 'filters': { 'cleaning': { - '()': 'authentic2.utils.CleanLogMessage', + '()': 'authentic2.utils.CleanLogMessage', }, 'request_context': { - '()': 'authentic2.log_filters.RequestContextFilter', + '()': 'authentic2.log_filters.RequestContextFilter', }, 'force_debug': { '()': 'authentic2.log_filters.ForceDebugFilter', @@ -233,15 +245,15 @@ LOGGING = { 'handlers': { 'console': { 'level': 'DEBUG', - 'class':'logging.StreamHandler', + 'class': 'logging.StreamHandler', 'formatter': 'verbose', 'filters': ['cleaning', 'request_context'], }, - # remove request_context filter for db log to prevent infinite loop - # when logging sql query to retrieve the session user + # remove request_context filter for db log to prevent infinite loop + # when logging sql query to retrieve the session user 'console_db': { 'level': 'DEBUG', - 'class':'logging.StreamHandler', + 'class': 'logging.StreamHandler', 'formatter': 'verbose_db', 'filters': ['cleaning'], }, @@ -250,16 +262,16 @@ LOGGING = { # even when debugging seeing SQL queries is too much, activate it # explicitly using DEBUG_DB 'django.db': { - 'handlers': ['console_db'], - 'level': logger.SettingsLogLevel('INFO', debug_setting='DEBUG_DB'), - 'propagate': False, + 'handlers': ['console_db'], + 'level': logger.SettingsLogLevel('INFO', debug_setting='DEBUG_DB'), + 'propagate': False, }, 'django': { - 'level': 'INFO', + 'level': 'INFO', }, # django_select2 outputs debug message at level INFO 'django_select2': { - 'level': 'WARNING', + 'level': 'WARNING', }, # lasso has the bad habit of logging everything as errors 'Lasso': { @@ -272,16 +284,16 @@ LOGGING = { 'filters': ['force_debug'], }, '': { - 'handlers': ['console'], - 'level': logger.SettingsLogLevel('INFO'), + 'handlers': ['console'], + 'level': logger.SettingsLogLevel('INFO'), }, }, } MIGRATION_MODULES = { - 'auth': 'authentic2.auth_migrations', - 'menu': 'authentic2.menu_migrations', - 'dashboard': 'authentic2.dashboard_migrations', + 'auth': 'authentic2.auth_migrations', + 'menu': 'authentic2.menu_migrations', + 'dashboard': 'authentic2.dashboard_migrations', } MIGRATION_MODULES['auth'] = 'authentic2.auth_migrations_18' diff --git a/src/authentic2/templates/registration/registration_completion_choose.html b/src/authentic2/templates/registration/registration_completion_choose.html index 1b597006..8b670016 100644 --- a/src/authentic2/templates/registration/registration_completion_choose.html +++ b/src/authentic2/templates/registration/registration_completion_choose.html @@ -1,16 +1,10 @@ {% extends "authentic2/base-page.html" %} {% load i18n %} -{% load breadcrumbs %} {% block title %} {% trans "Registration" %} {% endblock %} -{% block breadcrumbs %} - {{ block.super }} - {% breadcrumb_url 'Register' %} -{% endblock %} - {% block content %}

{% trans "Login" %}

diff --git a/src/authentic2/templates/registration/registration_completion_form.html b/src/authentic2/templates/registration/registration_completion_form.html index 7935bfe3..8cb506ec 100644 --- a/src/authentic2/templates/registration/registration_completion_form.html +++ b/src/authentic2/templates/registration/registration_completion_form.html @@ -1,16 +1,10 @@ {% extends "authentic2/base-page.html" %} {% load i18n %} -{% load breadcrumbs %} {% block title %} {% trans "Registration" %} {% endblock %} -{% block breadcrumbs %} - {{ block.super }} - {% breadcrumb_url 'Register' %} -{% endblock %} - {% block content %}

{% trans "Registration" %}

{% trans "Please fill the form to complete your registration" %}

diff --git a/src/authentic2/templates/registration/registration_form.html b/src/authentic2/templates/registration/registration_form.html index 68252c9b..c0216821 100644 --- a/src/authentic2/templates/registration/registration_form.html +++ b/src/authentic2/templates/registration/registration_form.html @@ -5,12 +5,6 @@ {{ view.title }} {% endblock %} -{% load breadcrumbs %} -{% block breadcrumbs %} -{{ block.super }} -{% breadcrumb_url 'Register' %} -{% endblock %} - {% block content %}

{{ view.title }}

diff --git a/src/authentic2/urls.py b/src/authentic2/urls.py index b35b1559..fecadd2c 100644 --- a/src/authentic2/urls.py +++ b/src/authentic2/urls.py @@ -1,44 +1,127 @@ +# 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 . + from django.conf.urls import url, include from django.conf import settings from django.contrib import admin +from django.contrib.auth.decorators import login_required +from django.contrib.auth import views as dj_auth_views from django.contrib.staticfiles.views import serve +from django.views.generic.base import TemplateView from django.views.static import serve as media_serve -from . import app_settings, plugins, views +from . import plugins, views admin.autodiscover() handler500 = 'authentic2.views.server_error' -urlpatterns = [ - url(r'^$', views.homepage, name='auth_homepage'), - url(r'test_redirect/$', views.test_redirect) +accounts_urlpatterns = [ + url(r'^activate/(?P[\w: -]+)/$', + views.registration_completion, name='registration_activate'), + url(r'^register/$', + views.RegistrationView.as_view(), + name='registration_register'), + url(r'^register/complete/$', + views.registration_complete, + name='registration_complete'), + url(r'^register/closed/$', + TemplateView.as_view(template_name='registration/registration_closed.html'), + name='registration_disallowed'), + url(r'^delete/$', + login_required(views.DeleteView.as_view()), + name='delete_account'), + url(r'^logged-in/$', + views.logged_in, + name='logged-in'), + url(r'^edit/$', + views.edit_profile, + name='profile_edit'), + url(r'^edit/(?P[-\w]+)/$', + views.edit_profile, + name='profile_edit_with_scope'), + url(r'^change-email/$', + views.email_change, + name='email-change'), + url(r'^change-email/verify/$', + views.email_change_verify, + name='email-change-verify'), + url(r'^$', + views.profile, + name='account_management'), + + # Password change + url(r'^password/change/$', + views.password_change, + name='password_change'), + url(r'^password/change/done/$', + dj_auth_views.password_change_done, + name='password_change_done'), + + # Password reset + url(r'^password/reset/confirm/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + views.password_reset_confirm, + name='password_reset_confirm'), + url(r'^password/reset/$', + views.password_reset, + name='password_reset'), + + url(r'^switch-back/$', + views.switch_back, + name='a2-switch-back'), + + # Legacy, only there to provide old view names to resolver + url(r'^password/change/$', + views.notimplemented_view, + name='auth_password_change'), + url(r'^password/change/done/$', + views.notimplemented_view, + name='auth_password_change_done'), + + url(r'^password/reset/confirm/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + views.notimplemented_view, + name='auth_password_reset_confirm'), + url(r'^password/reset/$', + views.notimplemented_view, + name='auth_password_reset'), + url(r'^password/reset/complete/$', + views.notimplemented_view, + name='auth_password_reset_complete'), + url(r'^password/reset/done/$', + views.notimplemented_view, + name='auth_password_reset_done'), ] -not_homepage_patterns = [ +urlpatterns = [ + url(r'^$', views.homepage, name='auth_homepage'), url(r'^login/$', views.login, name='auth_login'), url(r'^logout/$', views.logout, name='auth_logout'), - url(r'^redirect/(.*)', views.redirect, name='auth_redirect'), - url(r'^accounts/', include('authentic2.profile_urls')) -] - -not_homepage_patterns += [ - url(r'^accounts/', include(app_settings.A2_REGISTRATION_URLCONF)), + url(r'^accounts/', include(accounts_urlpatterns)), url(r'^admin/', include(admin.site.urls)), url(r'^idp/', include('authentic2.idp.urls')), url(r'^manage/', include('authentic2.manager.urls')), - url(r'^api/', include('authentic2.api_urls')) + url(r'^api/', include('authentic2.api_urls')), ] - -urlpatterns += not_homepage_patterns - try: if getattr(settings, 'DISCO_SERVICE', False): urlpatterns += [ (r'^disco_service/', include('disco_service.disco_responder')), ] -except: +except Exception: pass if settings.DEBUG: @@ -46,8 +129,10 @@ if settings.DEBUG: url(r'^static/(?P.*)$', serve) ] urlpatterns += [ - url(r'^media/(?P.*)$', media_serve, { - 'document_root': settings.MEDIA_ROOT}) + url(r'^media/(?P.*)$', media_serve, + { + 'document_root': settings.MEDIA_ROOT + }) ] if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS: diff --git a/src/authentic2/user_login_failure.py b/src/authentic2/user_login_failure.py index 0154450b..7095f44c 100644 --- a/src/authentic2/user_login_failure.py +++ b/src/authentic2/user_login_failure.py @@ -1,3 +1,19 @@ +# 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 logging import hashlib @@ -6,18 +22,21 @@ from django.utils.encoding import smart_bytes from . import app_settings + def key(identifier): return 'user-login-failure-%s' % hashlib.md5(smart_bytes(identifier)).hexdigest() + def user_login_success(identifier): cache.delete(key(identifier)) + def user_login_failure(identifier): cache.add(key(identifier), 0) count = cache.incr(key(identifier)) logger = logging.getLogger('authentic2.user_login_failure') logger.info(u'user %s failed to login', identifier) - if app_settings.A2_LOGIN_FAILURE_COUNT_BEFORE_WARNING and count >= app_settings.A2_LOGIN_FAILURE_COUNT_BEFORE_WARNING: - logger.warning(u'user %s failed to login more than %d times in a row', - identifier, count) + if (app_settings.A2_LOGIN_FAILURE_COUNT_BEFORE_WARNING + and count >= app_settings.A2_LOGIN_FAILURE_COUNT_BEFORE_WARNING): + logger.warning(u'user %s failed to login more than %d times in a row', identifier, count) diff --git a/src/authentic2/utils.py b/src/authentic2/utils.py index 1668572f..0fd9bf90 100644 --- a/src/authentic2/utils.py +++ b/src/authentic2/utils.py @@ -1,3 +1,19 @@ +# 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 inspect import random import time @@ -21,21 +37,19 @@ from django.contrib.auth import (REDIRECT_FIELD_NAME, login as auth_login, SESSI from django import forms from django.forms.utils import ErrorList, to_current_timezone from django.utils import timezone -from django.utils import html, http, six, encoding +from django.utils import html, six, encoding from django.utils.translation import ugettext as _, ungettext from django.utils.six.moves.urllib import parse as urlparse from django.shortcuts import resolve_url from django.template.loader import render_to_string, TemplateDoesNotExist from django.core.mail import send_mail from django.core import signing -from django.core.urlresolvers import reverse, NoReverseMatch +from django.core.urlresolvers import reverse from django.utils.formats import localize from django.contrib import messages -from django.utils import six from django.utils.functional import empty, allow_lazy from django.utils.http import urlsafe_base64_encode from django.utils.encoding import iri_to_uri, force_bytes, uri_to_iri -from django.utils import six from django.shortcuts import render @@ -141,7 +155,7 @@ def load_backend(path): mod = import_module(module) except ImportError as e: raise ImproperlyConfigured('Error importing idp backend %s: "%s"' % (module, e)) - except ValueError as e: + except ValueError: raise ImproperlyConfigured('Error importing idp backends. Is IDP_BACKENDS a correctly ' 'defined list or tuple?') try: @@ -200,12 +214,12 @@ def get_authenticator_method(authenticator, method, parameters): content = response.content status_code = response.status_code return { - 'id': authenticator.id, - 'name': authenticator.name, - 'content': content, - 'response': response, - 'status_code': status_code, - 'authenticator': authenticator, + 'id': authenticator.id, + 'name': authenticator.name, + 'content': content, + 'response': response, + 'status_code': status_code, + 'authenticator': authenticator, } @@ -252,7 +266,7 @@ def is_valid_url(url): parsed = urlparse.urlparse(url) if parsed.scheme in ('http', 'https', ''): return True - except: + except Exception: return False @@ -445,8 +459,8 @@ def generate_password(): (6, 'ABCDEFGHJKLMNPQRSTUVWXYZ'), (1, '%$/\\#@!')) parts = [] - for count, alphabet in composition: - for i in range(count): + for cnt, alphabet in composition: + for i in range(cnt): parts.append(random.SystemRandom().choice(alphabet)) random.shuffle(parts, random.SystemRandom().random) return ''.join(parts) @@ -577,7 +591,7 @@ def get_fields_and_labels(*args): if isinstance(field, (list, tuple)): field, label = field labels[field] = label - if not field in fields: + if field not in fields: fields.append(field) return fields, labels @@ -906,7 +920,10 @@ def get_next_url(params, field_name=None): def select_next_url(request, default, field_name=None, include_post=False, replace=None): '''Select the first valid next URL''' - next_url = (include_post and get_next_url(request.POST, field_name=field_name)) or get_next_url(request.GET, field_name=field_name) + next_url = ( + (include_post and get_next_url(request.POST, field_name=field_name)) + or get_next_url(request.GET, field_name=field_name) + ) if good_next_url(request, next_url): if replace: for key, value in replace.items(): diff --git a/src/authentic2/validators.py b/src/authentic2/validators.py index e4f90661..dc992b09 100644 --- a/src/authentic2/validators.py +++ b/src/authentic2/validators.py @@ -1,3 +1,19 @@ +# 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 . + from __future__ import unicode_literals import smtplib @@ -13,7 +29,7 @@ import dns.exception from . import app_settings # keep those symbols here for retrocompatibility -from .passwords import password_help_text, validate_password +from .passwords import password_help_text, validate_password # noqa: F401 # copied from http://www.djangotips.com/real-email-validation diff --git a/src/authentic2/views.py b/src/authentic2/views.py index fdcc6022..c7bee83c 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -1,19 +1,33 @@ +# 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 collections import logging -from authentic2.compat_lasso import lasso -import requests +import random import re -import collections - from django.conf import settings -from django.shortcuts import render_to_response, render -from django.template.loader import render_to_string, select_template +from django.shortcuts import render, get_object_or_404 +from django.template.loader import render_to_string from django.views.generic.edit import UpdateView, FormView -from django.views.generic import RedirectView, TemplateView +from django.views.generic import TemplateView from django.views.generic.base import View from django.contrib.auth import SESSION_KEY from django import http, shortcuts -from django.core import mail, signing +from django.core import signing from django.core.urlresolvers import reverse from django.core.exceptions import ValidationError from django.contrib import messages @@ -21,35 +35,36 @@ from django.utils import six from django.utils.translation import ugettext as _ from django.contrib.auth import logout as auth_logout from django.contrib.auth import REDIRECT_FIELD_NAME -from django.http import (HttpResponseRedirect, HttpResponseForbidden, - HttpResponse) -from django.core.exceptions import PermissionDenied +from django.contrib.auth.views import password_change as dj_password_change +from django.http import (HttpResponseRedirect, HttpResponseForbidden, HttpResponse) from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie from django.views.decorators.cache import never_cache +from django.views.decorators.debug import sensitive_post_parameters from django.contrib.auth.decorators import login_required from django.db.models.fields import FieldDoesNotExist from django.db.models.query import Q - -# FIXME: this decorator has nothing to do with an idp, should be moved in the -# a2 package -# FIXME: this constant should be moved in the a2 package - - -from . import (utils, app_settings, forms, compat, decorators, constants, models, cbv, hooks) - +from django.contrib.auth import get_user_model, authenticate +from django.http import Http404 +from django.utils.http import urlsafe_base64_decode +from django.views.generic.edit import CreateView +from django.forms import CharField, Form +from django.core.urlresolvers import reverse_lazy +from django.http import HttpResponseBadRequest + +from . import (utils, app_settings, compat, decorators, constants, + models, cbv, hooks, validators) +from .a2_rbac.utils import get_default_ou +from .a2_rbac.models import OrganizationalUnit as OU +from .forms import ( + passwords as passwords_forms, + registration as registration_forms, + profile as profile_forms) + +User = get_user_model() logger = logging.getLogger(__name__) -def redirect(request, next, template_name='redirect.html'): - '''Show a simple page which does a javascript redirect, closing any popup - enclosing us''' - if not next.startswith('http'): - next = '/%s%s' % (request.get_host(), next) - logging.info('Redirect to %r' % next) - return render_to_response(template_name, { 'next': next }) - - def server_error(request, template_name='500.html'): """ 500 error handler. @@ -61,7 +76,7 @@ def server_error(request, template_name='500.html'): class EditProfile(cbv.HookMixin, cbv.TemplateNamesMixin, UpdateView): - model = compat.get_user_model() + model = User template_names = ['profiles/edit_profile.html', 'authentic2/accounts_edit.html'] title = _('Edit account data') @@ -100,8 +115,7 @@ class EditProfile(cbv.HookMixin, cbv.TemplateNamesMixin, UpdateView): else: default_fields = list(attributes.values_list('name', flat=True)) fields, labels = utils.get_fields_and_labels( - editable_profile_fields, - default_fields) + editable_profile_fields, default_fields) if scopes: # restrict fields to those in the scopes fields = [field for field in fields if field in default_fields] @@ -115,9 +129,10 @@ class EditProfile(cbv.HookMixin, cbv.TemplateNamesMixin, UpdateView): fields, labels = self.get_fields(scopes=scopes) # Email must be edited through the change email view, as it needs validation fields = [field for field in fields if field != 'email'] - return forms.modelform_factory(compat.get_user_model(), fields=fields, - labels=labels, - form=forms.EditProfileForm) + return profile_forms.modelform_factory( + User, fields=fields, + labels=labels, + form=profile_forms.EditProfileForm) def get_object(self): return self.request.user @@ -154,7 +169,7 @@ def su(request, username, redirect_url='/'): url(r'^su/(?P.*)/$', 'authentic2.views.su', {'redirect_url': '/'}), ''' if request.user.is_superuser or request.session.get('has_superuser_power'): - su_user = shortcuts.get_object_or_404(compat.get_user_model(), username=username) + su_user = shortcuts.get_object_or_404(User, username=username) if su_user.is_active: request.session[SESSION_KEY] = su_user.id request.session['has_superuser_power'] = True @@ -173,8 +188,8 @@ class EmailChangeView(cbv.TemplateNamesMixin, FormView): def get_form_class(self): if self.request.user.has_usable_password(): - return forms.EmailChangeForm - return forms.EmailChangeFormNoPassword + return profile_forms.EmailChangeForm + return profile_forms.EmailChangeFormNoPassword def get_form_kwargs(self): kwargs = super(EmailChangeView, self).get_form_kwargs() @@ -196,7 +211,7 @@ class EmailChangeView(cbv.TemplateNamesMixin, FormView): 'is received. An email of validation ' 'was sent to you. Please click on the ' 'link contained inside.')) - logging.getLogger(__name__).info('email change request') + logger.info('email change request') return super(EmailChangeView, self).form_valid(form) email_change = decorators.setting_enabled('A2_PROFILE_CAN_CHANGE_EMAIL')( @@ -206,7 +221,6 @@ email_change = decorators.setting_enabled('A2_PROFILE_CAN_CHANGE_EMAIL')( class EmailChangeVerifyView(TemplateView): def get(self, request, *args, **kwargs): if 'token' in request.GET: - User = compat.get_user_model() try: token = signing.loads(request.GET['token'], max_age=app_settings.A2_EMAIL_CHANGE_TOKEN_LIFETIME) @@ -225,24 +239,22 @@ class EmailChangeVerifyView(TemplateView): user.email = email user.email_verified = True user.save() - messages.info(request, _('your request for changing your email for {0} ' - 'is successful').format(email)) - logging.getLogger(__name__).info('user %s changed its email ' - 'from %s to %s', user, - old_email, email) + messages.info(request, + _('your request for changing your email for {0} is successful').format(email)) + logger.info('user %s changed its email from %s to %s', user, old_email, email) hooks.call_hooks('event', name='change-email-confirm', user=user, email=email) except signing.SignatureExpired: - messages.error(request, _('your request for changing your email is too ' - 'old, try again')) + messages.error(request, + _('your request for changing your email is too old, try again')) except signing.BadSignature: - messages.error(request, _('your request for changing your email is ' - 'invalid, try again')) + messages.error(request, + _('your request for changing your email is invalid, try again')) except ValueError: - messages.error(request, _('your request for changing your email was not ' - 'on this site, try again')) + messages.error(request, + _('your request for changing your email was not on this site, try again')) except User.DoesNotExist: - messages.error(request, _('your request for changing your email is for ' - 'an unknown user, try again')) + messages.error(request, + _('your request for changing your email is for an unknown user, try again')) except ValidationError as e: messages.error(request, e.message) else: @@ -252,8 +264,6 @@ class EmailChangeVerifyView(TemplateView): email_change_verify = EmailChangeVerifyView.as_view() -logger = logging.getLogger('authentic2.idp.views') - @csrf_exempt @ensure_csrf_cookie @@ -264,8 +274,8 @@ def login(request, template_name='authentic2/login.html', # redirect user to homepage if already connected, if setting # A2_LOGIN_REDIRECT_AUTHENTICATED_USERS_TO_HOMEPAGE is True - if (request.user.is_authenticated() and - app_settings.A2_LOGIN_REDIRECT_AUTHENTICATED_USERS_TO_HOMEPAGE): + if (request.user.is_authenticated() + and app_settings.A2_LOGIN_REDIRECT_AUTHENTICATED_USERS_TO_HOMEPAGE): return utils.redirect(request, 'auth_homepage') redirect_to = request.GET.get(redirect_field_name) @@ -308,9 +318,9 @@ def login(request, template_name='authentic2/login.html', form_class = authenticator.form() submit_name = 'submit-%s' % fid block = { - 'id': fid, - 'name': name, - 'authenticator': authenticator + 'id': fid, + 'name': name, + 'authenticator': authenticator } if request.method == 'POST' and submit_name in request.POST: form = form_class(data=request.POST) @@ -322,7 +332,7 @@ def login(request, template_name='authentic2/login.html', else: block['form'] = form_class() blocks.append(block) - else: # New frontends API + else: # New frontends API parameters = {'request': request, 'context': context} block = utils.get_authenticator_method(authenticator, 'login', parameters) @@ -337,24 +347,21 @@ def login(request, template_name='authentic2/login.html', else: blocks[-1]['is_hidden'] = False - # Old frontends API for block in blocks: fid = block['id'] - if not 'form' in block: + if 'form' not in block: continue authenticator = block['authenticator'] context.update({ - 'submit_name': 'submit-%s' % fid, - redirect_field_name: redirect_to, - 'form': block['form'] + 'submit_name': 'submit-%s' % fid, + redirect_field_name: redirect_to, + 'form': block['form'] }) if hasattr(authenticator, 'get_context'): context.update(authenticator.get_context()) sub_template_name = authenticator.template() - block['content'] = render_to_string( - sub_template_name, context, - request=request) + block['content'] = render_to_string(sub_template_name, context, request=request) request.session.set_test_cookie() @@ -423,7 +430,7 @@ class ProfileView(cbv.TemplateNamesMixin, TemplateView): for field_name in getattr(request.user, 'USER_PROFILE', []): if field_name not in field_names: field_names.append(field_name) - qs = models.Attribute.objects.filter(Q(user_editable=True)|Q(user_visible=True)) + qs = models.Attribute.objects.filter(Q(user_editable=True) | Q(user_visible=True)) qs = qs.values_list('name', flat=True) for field_name in qs: if field_name not in field_names: @@ -479,8 +486,7 @@ class ProfileView(cbv.TemplateNamesMixin, TemplateView): # Credentials management parameters = {'request': request, 'context': context} - profiles = [utils.get_authenticator_method(frontend, 'profile', parameters) - for frontend in frontends] + profiles = [utils.get_authenticator_method(frontend, 'profile', parameters) for frontend in frontends] # Old frontends data structure for templates blocks = [block['content'] for block in profiles if block] # New frontends data structure for templates @@ -510,17 +516,24 @@ class ProfileView(cbv.TemplateNamesMixin, TemplateView): profile = login_required(ProfileView.as_view()) + def logout_list(request): '''Return logout links from idp backends''' return utils.accumulate_from_backends(request, 'logout_list') + def redirect_logout_list(request): '''Return redirect logout links from idp backends''' return utils.accumulate_from_backends(request, 'redirect_logout_list') -def logout(request, next_url=None, default_next_url='auth_homepage', - redirect_field_name=REDIRECT_FIELD_NAME, - template='authentic2/logout.html', do_local=True, check_referer=True): + +def logout(request, + next_url=None, + default_next_url='auth_homepage', + redirect_field_name=REDIRECT_FIELD_NAME, + template='authentic2/logout.html', + do_local=True, + check_referer=True): '''Logout first check if a logout request is authorized, i.e. that logout was done using a POST with CSRF token or with a GET from the same site. @@ -528,10 +541,8 @@ def logout(request, next_url=None, default_next_url='auth_homepage', Logout endpoints of IdP module must re-user the view by setting check_referer and do_local to False. ''' - logger = logging.getLogger(__name__) default_next_url = utils.make_url(default_next_url) - next_url = next_url or request.GET.get(redirect_field_name, - default_next_url) + next_url = next_url or request.GET.get(redirect_field_name, default_next_url) ctx = {} ctx['next_url'] = next_url ctx['redir_timeout'] = 60 @@ -541,14 +552,14 @@ def logout(request, next_url=None, default_next_url='auth_homepage', return render(request, 'authentic2/logout_confirm.html', ctx) do_local = do_local and 'local' in request.GET if not do_local: - l = logout_list(request) - if l: + fragments = logout_list(request) + if fragments: # Full logout with iframes next_url = utils.make_url('auth_logout', params={ 'local': 'ok', REDIRECT_FIELD_NAME: next_url}) ctx['next_url'] = next_url - ctx['logout_list'] = l + ctx['logout_list'] = fragments ctx['message'] = _('Logging out from all your services') return render(request, template, ctx) # Get redirection targets for full logout with redirections @@ -613,13 +624,545 @@ class LoggedInView(View): logged_in = never_cache(LoggedInView.as_view()) + def csrf_failure_view(request, reason=""): messages.warning(request, _('The page is out of date, it was reloaded for you')) return HttpResponseRedirect(request.get_full_path()) -def test_redirect(request): - next_url = request.GET.get(REDIRECT_FIELD_NAME, settings.LOGIN_REDIRECT_URL) - messages.info(request, 'Une info') - messages.warning(request, 'Un warning') - messages.error(request, 'Une erreur') - return HttpResponseRedirect(next_url) + +class PasswordResetView(cbv.NextURLViewMixin, FormView): + '''Ask for an email and send a password reset link by mail''' + form_class = passwords_forms.PasswordResetForm + title = _('Password Reset') + + def get_template_names(self): + return [ + 'authentic2/password_reset_form.html', + 'registration/password_reset_form.html', + ] + + def get_form_kwargs(self, **kwargs): + kwargs = super(PasswordResetView, self).get_form_kwargs(**kwargs) + initial = kwargs.setdefault('initial', {}) + initial['next_url'] = self.request.GET.get(REDIRECT_FIELD_NAME, '') + return kwargs + + def get_context_data(self, **kwargs): + ctx = super(PasswordResetView, self).get_context_data(**kwargs) + if app_settings.A2_USER_CAN_RESET_PASSWORD is False: + raise Http404('Password reset is not allowed.') + ctx['title'] = _('Password reset') + return ctx + + def form_valid(self, form): + form.save() + # return to next URL + messages.info(self.request, _('If your email address exists in our ' + 'database, you will receive an email ' + 'containing instructions to reset ' + 'your password')) + return super(PasswordResetView, self).form_valid(form) + +password_reset = PasswordResetView.as_view() + + +class PasswordResetConfirmView(cbv.RedirectToNextURLViewMixin, FormView): + '''Validate password reset link, show a set password form and login + the user. + ''' + form_class = passwords_forms.SetPasswordForm + title = _('Password Reset') + + def get_template_names(self): + return [ + 'registration/password_reset_confirm.html', + 'authentic2/password_reset_confirm.html', + ] + + def dispatch(self, request, *args, **kwargs): + validlink = True + uidb64 = kwargs['uidb64'] + self.token = token = kwargs['token'] + + UserModel = get_user_model() + # checked by URLconf + assert uidb64 is not None and token is not None + try: + uid = urlsafe_base64_decode(uidb64) + # use authenticate to eventually get an LDAPUser + self.user = authenticate(user=UserModel._default_manager.get(pk=uid)) + except (TypeError, ValueError, OverflowError, + UserModel.DoesNotExist): + validlink = False + messages.warning(request, _('User not found')) + + if validlink and not compat.default_token_generator.check_token(self.user, token): + validlink = False + messages.warning(request, _('You reset password link is invalid or has expired')) + if not validlink: + return utils.redirect(request, self.get_success_url()) + can_reset_password = utils.get_user_flag(user=self.user, + name='can_reset_password', + default=self.user.has_usable_password()) + if not can_reset_password: + messages.warning( + request, + _('It\'s not possible to reset your password. Please contact an administrator.')) + return utils.redirect(request, self.get_success_url()) + return super(PasswordResetConfirmView, self).dispatch(request, *args, + **kwargs) + + def get_context_data(self, **kwargs): + ctx = super(PasswordResetConfirmView, self).get_context_data(**kwargs) + # compatibility with existing templates ! + ctx['title'] = _('Enter new password') + ctx['validlink'] = True + return ctx + + def get_form_kwargs(self): + kwargs = super(PasswordResetConfirmView, self).get_form_kwargs() + kwargs['user'] = self.user + return kwargs + + def form_valid(self, form): + # Changing password by mail validate the email + form.user.email_verified = True + form.save() + hooks.call_hooks('event', name='password-reset-confirm', user=form.user, token=self.token, + form=form) + logger.info(u'user %s resetted its password with token %r...', + self.user, self.token[:9]) + return self.finish() + + def finish(self): + return utils.simulate_authentication(self.request, self.user, 'email') + +password_reset_confirm = PasswordResetConfirmView.as_view() + + +def switch_back(request): + return utils.switch_back(request) + + +def valid_token(method): + def f(request, *args, **kwargs): + try: + request.token = signing.loads(kwargs['registration_token'].replace(' ', ''), + max_age=settings.ACCOUNT_ACTIVATION_DAYS * 3600 * 24) + except signing.SignatureExpired: + messages.warning(request, _('Your activation key is expired')) + return utils.redirect(request, 'registration_register') + except signing.BadSignature: + messages.warning(request, _('Activation failed')) + return utils.redirect(request, 'registration_register') + return method(request, *args, **kwargs) + return f + + +class BaseRegistrationView(FormView): + form_class = registration_forms.RegistrationForm + template_name = 'registration/registration_form.html' + title = _('Registration') + + def dispatch(self, request, *args, **kwargs): + if not getattr(settings, 'REGISTRATION_OPEN', True): + raise Http404('Registration is not open.') + self.token = {} + self.ou = get_default_ou() + # load pre-filled values + if request.GET.get('token'): + try: + self.token = signing.loads( + request.GET.get('token'), + max_age=settings.ACCOUNT_ACTIVATION_DAYS * 3600 * 24) + except (TypeError, ValueError, signing.BadSignature) as e: + logger.warning(u'registration_view: invalid token: %s', e) + return HttpResponseBadRequest('invalid token', content_type='text/plain') + if 'ou' in self.token: + self.ou = OU.objects.get(pk=self.token['ou']) + self.next_url = self.token.pop(REDIRECT_FIELD_NAME, utils.select_next_url(request, None)) + return super(BaseRegistrationView, self).dispatch(request, *args, **kwargs) + + def form_valid(self, form): + email = form.cleaned_data.pop('email') + for field in form.cleaned_data: + self.token[field] = form.cleaned_data[field] + + # propagate service to the registration completion view + if constants.SERVICE_FIELD_NAME in self.request.GET: + self.token[constants.SERVICE_FIELD_NAME] = \ + self.request.GET[constants.SERVICE_FIELD_NAME] + + self.token.pop(REDIRECT_FIELD_NAME, None) + self.token.pop('email', None) + + utils.send_registration_mail(self.request, email, next_url=self.next_url, + ou=self.ou, **self.token) + self.request.session['registered_email'] = email + return utils.redirect(self.request, 'registration_complete', params={REDIRECT_FIELD_NAME: self.next_url}) + + def get_context_data(self, **kwargs): + context = super(BaseRegistrationView, self).get_context_data(**kwargs) + parameters = {'request': self.request, + 'context': context} + blocks = [utils.get_authenticator_method(authenticator, 'registration', parameters) + for authenticator in utils.get_backends('AUTH_FRONTENDS')] + context['frontends'] = collections.OrderedDict((block['id'], block) + for block in blocks if block) + return context + + +class RegistrationView(cbv.ValidateCSRFMixin, BaseRegistrationView): + pass + + +class RegistrationCompletionView(CreateView): + model = get_user_model() + success_url = 'auth_homepage' + + def get_template_names(self): + if self.users and 'create' not in self.request.GET: + return ['registration/registration_completion_choose.html'] + else: + return ['registration/registration_completion_form.html'] + + def get_success_url(self): + try: + redirect_url, next_field = app_settings.A2_REGISTRATION_REDIRECT + except Exception: + redirect_url = app_settings.A2_REGISTRATION_REDIRECT + next_field = REDIRECT_FIELD_NAME + + if self.token and self.token.get(REDIRECT_FIELD_NAME): + url = self.token[REDIRECT_FIELD_NAME] + if redirect_url: + url = utils.make_url(redirect_url, params={next_field: url}) + else: + if redirect_url: + url = redirect_url + else: + url = utils.make_url(self.success_url) + return url + + def dispatch(self, request, *args, **kwargs): + self.token = request.token + self.authentication_method = self.token.get('authentication_method', 'email') + self.email = request.token['email'] + if 'ou' in self.token: + self.ou = OU.objects.get(pk=self.token['ou']) + else: + self.ou = get_default_ou() + self.users = User.objects.filter(email__iexact=self.email) \ + .order_by('date_joined') + if self.ou: + self.users = self.users.filter(ou=self.ou) + self.email_is_unique = app_settings.A2_EMAIL_IS_UNIQUE \ + or app_settings.A2_REGISTRATION_EMAIL_IS_UNIQUE + if self.ou: + self.email_is_unique |= self.ou.email_is_unique + self.init_fields_labels_and_help_texts() + # if registration is done during an SSO add the service to the registration event + self.service = self.token.get(constants.SERVICE_FIELD_NAME) + return super(RegistrationCompletionView, self) \ + .dispatch(request, *args, **kwargs) + + def init_fields_labels_and_help_texts(self): + attributes = models.Attribute.objects.filter( + asked_on_registration=True) + default_fields = attributes.values_list('name', flat=True) + required_fields = models.Attribute.objects.filter(required=True) \ + .values_list('name', flat=True) + fields, labels = utils.get_fields_and_labels( + app_settings.A2_REGISTRATION_FIELDS, + default_fields, + app_settings.A2_REGISTRATION_REQUIRED_FIELDS, + app_settings.A2_REQUIRED_FIELDS, + models.Attribute.objects.filter(required=True).values_list('name', flat=True)) + help_texts = {} + if app_settings.A2_REGISTRATION_FORM_USERNAME_LABEL: + labels['username'] = app_settings.A2_REGISTRATION_FORM_USERNAME_LABEL + if app_settings.A2_REGISTRATION_FORM_USERNAME_HELP_TEXT: + help_texts['username'] = \ + app_settings.A2_REGISTRATION_FORM_USERNAME_HELP_TEXT + required = list(app_settings.A2_REGISTRATION_REQUIRED_FIELDS) + \ + list(required_fields) + if 'email' in fields: + fields.remove('email') + for field in self.token.get('skip_fields') or []: + if field in fields: + fields.remove(field) + self.fields = fields + self.labels = labels + self.required = required + self.help_texts = help_texts + + def get_form_class(self): + if not self.token.get('valid_email', True): + self.fields.append('email') + self.required.append('email') + form_class = registration_forms.RegistrationCompletionForm + if self.token.get('no_password', False): + form_class = registration_forms.RegistrationCompletionFormNoPassword + form_class = profile_forms.modelform_factory( + self.model, + form=form_class, + fields=self.fields, + labels=self.labels, + required=self.required, + help_texts=self.help_texts) + if 'username' in self.fields and app_settings.A2_REGISTRATION_FORM_USERNAME_REGEX: + # Keep existing field label and help_text + old_field = form_class.base_fields['username'] + field = CharField( + max_length=256, + label=old_field.label, + help_text=old_field.help_text, + validators=[validators.UsernameValidator()]) + form_class = type('RegistrationForm', (form_class,), {'username': field}) + return form_class + + def get_form_kwargs(self, **kwargs): + '''Initialize mail from token''' + kwargs = super(RegistrationCompletionView, self).get_form_kwargs(**kwargs) + if 'ou' in self.token: + ou = get_object_or_404(OU, id=self.token['ou']) + else: + ou = get_default_ou() + + attributes = {'email': self.email, 'ou': ou} + for key in self.token: + if key in app_settings.A2_PRE_REGISTRATION_FIELDS: + attributes[key] = self.token[key] + logger.debug(u'attributes %s', attributes) + + prefilling_list = utils.accumulate_from_backends(self.request, 'registration_form_prefill') + logger.debug(u'prefilling_list %s', prefilling_list) + # Build a single meaningful prefilling with sets of values + prefilling = {} + for p in prefilling_list: + for name, values in p.items(): + if name in self.fields: + prefilling.setdefault(name, set()).update(values) + logger.debug(u'prefilling %s', prefilling) + + for name, values in prefilling.items(): + attributes[name] = ' '.join(values) + logger.debug(u'attributes with prefilling %s', attributes) + + if self.token.get('user_id'): + kwargs['instance'] = User.objects.get(id=self.token.get('user_id')) + else: + init_kwargs = {} + for key in ('email', 'first_name', 'last_name', 'ou'): + if key in attributes: + init_kwargs[key] = attributes[key] + kwargs['instance'] = get_user_model()(**init_kwargs) + + return kwargs + + def get_form(self, form_class=None): + form = super(RegistrationCompletionView, self).get_form(form_class=form_class) + hooks.call_hooks('front_modify_form', self, form) + return form + + def get_context_data(self, **kwargs): + ctx = super(RegistrationCompletionView, self).get_context_data(**kwargs) + ctx['token'] = self.token + ctx['users'] = self.users + ctx['email'] = self.email + ctx['email_is_unique'] = self.email_is_unique + ctx['create'] = 'create' in self.request.GET + return ctx + + def get(self, request, *args, **kwargs): + if len(self.users) == 1 and self.email_is_unique: + # Found one user, EMAIL is unique, log her in + utils.simulate_authentication( + request, self.users[0], + method=self.authentication_method, + service_slug=self.service) + return utils.redirect(request, self.get_success_url()) + confirm_data = self.token.get('confirm_data', False) + + if confirm_data == 'required': + fields_to_confirm = self.required + else: + fields_to_confirm = self.fields + if (all(field in self.token for field in fields_to_confirm) + and (not confirm_data or confirm_data == 'required')): + # We already have every fields + form_kwargs = self.get_form_kwargs() + form_class = self.get_form_class() + data = self.token + if 'password' in data: + data['password1'] = data['password'] + data['password2'] = data['password'] + del data['password'] + form_kwargs['data'] = data + form = form_class(**form_kwargs) + if form.is_valid(): + user = form.save() + return self.registration_success(request, user, form) + self.get_form = lambda *args, **kwargs: form + return super(RegistrationCompletionView, self).get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + if self.users and self.email_is_unique: + # email is unique, users already exist, creating a new one is forbidden ! + return utils.redirect( + request, request.resolver_match.view_name, args=self.args, + kwargs=self.kwargs) + if 'uid' in request.POST: + uid = request.POST['uid'] + for user in self.users: + if str(user.id) == uid: + utils.simulate_authentication( + request, user, + method=self.authentication_method, + service_slug=self.service) + return utils.redirect(request, self.get_success_url()) + return super(RegistrationCompletionView, self).post(request, *args, **kwargs) + + def form_valid(self, form): + + # remove verified fields from form, this allows an authentication + # method to provide verified data fields and to present it to the user, + # while preventing the user to modify them. + for av in models.AttributeValue.objects.with_owner(form.instance): + if av.verified and av.attribute.name in form.fields: + del form.fields[av.attribute.name] + + if ('email' in self.request.POST + and ('email' not in self.token or self.request.POST['email'] != self.token['email']) + and not self.token.get('skip_email_check')): + # If an email is submitted it must be validated or be the same as in the token + data = form.cleaned_data + data['no_password'] = self.token.get('no_password', False) + utils.send_registration_mail( + self.request, + ou=self.ou, + next_url=self.get_success_url(), + **data) + self.request.session['registered_email'] = form.cleaned_data['email'] + return utils.redirect(self.request, 'registration_complete') + super(RegistrationCompletionView, self).form_valid(form) + return self.registration_success(self.request, form.instance, form) + + def registration_success(self, request, user, form): + hooks.call_hooks('event', name='registration', user=user, form=form, view=self, + authentication_method=self.authentication_method, + token=request.token, service=self.service) + utils.simulate_authentication( + request, user, + method=self.authentication_method, + service_slug=self.service) + messages.info(self.request, _('You have just created an account.')) + self.send_registration_success_email(user) + return utils.redirect(request, self.get_success_url()) + + def send_registration_success_email(self, user): + if not user.email: + return + + template_names = [ + 'authentic2/registration_success' + ] + login_url = self.request.build_absolute_uri(settings.LOGIN_URL) + utils.send_templated_mail(user, template_names=template_names, + context={ + 'user': user, + 'email': user.email, + 'site': self.request.get_host(), + 'login_url': login_url, + }, + request=self.request) + + +class DeleteView(FormView): + template_name = 'authentic2/accounts_delete.html' + success_url = reverse_lazy('auth_logout') + title = _('Delete account') + + def dispatch(self, request, *args, **kwargs): + if not app_settings.A2_REGISTRATION_CAN_DELETE_ACCOUNT: + return utils.redirect(request, '..') + return super(DeleteView, self).dispatch(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + if 'cancel' in request.POST: + return utils.redirect(request, 'account_management') + return super(DeleteView, self).post(request, *args, **kwargs) + + def get_form_class(self): + if self.request.user.has_usable_password(): + return profile_forms.DeleteAccountForm + return Form + + def get_form_kwargs(self, **kwargs): + kwargs = super(DeleteView, self).get_form_kwargs(**kwargs) + if self.request.user.has_usable_password(): + kwargs['user'] = self.request.user + return kwargs + + def form_valid(self, form): + utils.send_account_deletion_mail(self.request, self.request.user) + models.DeletedUser.objects.delete_user(self.request.user) + self.request.user.email += '#%d' % random.randint(1, 10000000) + self.request.user.email_verified = False + self.request.user.save(update_fields=['email', 'email_verified']) + logger.info(u'deletion of account %s requested', self.request.user) + hooks.call_hooks('event', name='delete-account', user=self.request.user) + messages.info(self.request, + _('Your account has been scheduled for deletion. You cannot use it anymore.')) + return super(DeleteView, self).form_valid(form) + +registration_completion = valid_token(RegistrationCompletionView.as_view()) + + +class RegistrationCompleteView(TemplateView): + template_name = 'registration/registration_complete.html' + + def get_context_data(self, **kwargs): + kwargs['next_url'] = utils.select_next_url(self.request, settings.LOGIN_REDIRECT_URL) + return super(RegistrationCompleteView, self).get_context_data( + account_activation_days=settings.ACCOUNT_ACTIVATION_DAYS, + **kwargs) + + +registration_complete = RegistrationCompleteView.as_view() + + +@sensitive_post_parameters() +@login_required +@decorators.setting_enabled('A2_REGISTRATION_CAN_CHANGE_PASSWORD') +def password_change(request, *args, **kwargs): + kwargs['password_change_form'] = passwords_forms.PasswordChangeForm + post_change_redirect = kwargs.pop('post_change_redirect', None) + if 'next_url' in request.POST and request.POST['next_url']: + post_change_redirect = request.POST['next_url'] + elif REDIRECT_FIELD_NAME in request.GET: + post_change_redirect = request.GET[REDIRECT_FIELD_NAME] + elif post_change_redirect is None: + post_change_redirect = reverse('account_management') + if not utils.user_can_change_password(request=request): + messages.warning(request, _('Password change is forbidden')) + return utils.redirect(request, post_change_redirect) + if 'cancel' in request.POST: + return utils.redirect(request, post_change_redirect) + kwargs['post_change_redirect'] = post_change_redirect + extra_context = kwargs.setdefault('extra_context', {}) + extra_context['view'] = password_change + extra_context[REDIRECT_FIELD_NAME] = post_change_redirect + if not request.user.has_usable_password(): + kwargs['password_change_form'] = passwords_forms.SetPasswordForm + response = dj_password_change(request, *args, **kwargs) + if isinstance(response, HttpResponseRedirect): + hooks.call_hooks('event', name='change-password', user=request.user, request=request) + messages.info(request, _('Password changed')) + return response +password_change.title = _('Password Change') +password_change.do_not_call_in_templates = True + + +def notimplemented_view(request): + raise NotImplementedError diff --git a/src/authentic2/widgets.py b/src/authentic2/widgets.py index 65b68627..8309cfb8 100644 --- a/src/authentic2/widgets.py +++ b/src/authentic2/widgets.py @@ -1,2 +1,18 @@ +# 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 . + # legacy module, please use authentic2.forms.widgets now. -from .forms.widgets import * +from .forms.widgets import * # noqa: F403,F401 diff --git a/src/authentic2/wsgi.py b/src/authentic2/wsgi.py index c6587358..55b65a4c 100644 --- a/src/authentic2/wsgi.py +++ b/src/authentic2/wsgi.py @@ -1,3 +1,19 @@ +# 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 . + """ WSGI config for a authentic2 project. @@ -15,12 +31,13 @@ framework. """ import os -from . import logger +# XXX: monkeypatch logging +from . import logger # noqa: F401 +from django.core.wsgi import get_wsgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentic2.settings") # This application object is used by any WSGI server configured to use this # file. This includes Django's development server, if the WSGI_APPLICATION # setting points here. -from django.core.wsgi import get_wsgi_application application = get_wsgi_application() diff --git a/src/authentic2_auth_oidc/__init__.py b/src/authentic2_auth_oidc/__init__.py index ae18320d..a03219d5 100644 --- a/src/authentic2_auth_oidc/__init__.py +++ b/src/authentic2_auth_oidc/__init__.py @@ -1,6 +1,21 @@ +# 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 logging -from django.utils.translation import ugettext_lazy as _ from django.core.urlresolvers import reverse from authentic2.utils import make_url diff --git a/src/authentic2_auth_oidc/admin.py b/src/authentic2_auth_oidc/admin.py index e6ec9ea3..a9221b66 100644 --- a/src/authentic2_auth_oidc/admin.py +++ b/src/authentic2_auth_oidc/admin.py @@ -1,3 +1,19 @@ +# 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 . + from django.contrib import admin from . import models diff --git a/src/authentic2_auth_oidc/app_settings.py b/src/authentic2_auth_oidc/app_settings.py index 95ac369a..e998d8b3 100644 --- a/src/authentic2_auth_oidc/app_settings.py +++ b/src/authentic2_auth_oidc/app_settings.py @@ -1,3 +1,22 @@ +# 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 sys + + class AppSettings(object): '''Thanks django-allauth''' __SENTINEL = object() @@ -18,9 +37,6 @@ class AppSettings(object): def ENABLE(self): return self._setting('ENABLE', True) - -import sys - app_settings = AppSettings('A2_AUTH_OIDC_') app_settings.__name__ = __name__ sys.modules[__name__] = app_settings diff --git a/src/authentic2_auth_oidc/authenticators.py b/src/authentic2_auth_oidc/authenticators.py index 4cac9a77..bbc0516d 100644 --- a/src/authentic2_auth_oidc/authenticators.py +++ b/src/authentic2_auth_oidc/authenticators.py @@ -1,3 +1,19 @@ +# 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 . + from django.utils.translation import gettext_noop from django.shortcuts import render diff --git a/src/authentic2_auth_oidc/backends.py b/src/authentic2_auth_oidc/backends.py index 3c105e8e..5720caba 100644 --- a/src/authentic2_auth_oidc/backends.py +++ b/src/authentic2_auth_oidc/backends.py @@ -1,3 +1,19 @@ +# 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 logging import datetime diff --git a/src/authentic2_auth_oidc/management/commands/oidc-register-issuer.py b/src/authentic2_auth_oidc/management/commands/oidc-register-issuer.py index 4d5199fb..4a0aea37 100644 --- a/src/authentic2_auth_oidc/management/commands/oidc-register-issuer.py +++ b/src/authentic2_auth_oidc/management/commands/oidc-register-issuer.py @@ -1,3 +1,19 @@ +# 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 . + from __future__ import print_function import json @@ -6,9 +22,8 @@ import pprint from django.core.management.base import BaseCommand, CommandError from django.core.exceptions import ValidationError -from django.utils.six import text_type +from django.db.transaction import atomic -from authentic2.compat import atomic from authentic2_auth_oidc.utils import register_issuer from authentic2_auth_oidc.models import OIDCClaimMapping, OIDCProvider diff --git a/src/authentic2_auth_oidc/managers.py b/src/authentic2_auth_oidc/managers.py index 678b7d60..721db684 100644 --- a/src/authentic2_auth_oidc/managers.py +++ b/src/authentic2_auth_oidc/managers.py @@ -1,3 +1,19 @@ +# 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 . + from django.db.models.query import QuerySet diff --git a/src/authentic2_auth_oidc/models.py b/src/authentic2_auth_oidc/models.py index ee2dbbae..74dd4d08 100644 --- a/src/authentic2_auth_oidc/models.py +++ b/src/authentic2_auth_oidc/models.py @@ -1,3 +1,19 @@ +# 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 uuid import json diff --git a/src/authentic2_auth_oidc/urls.py b/src/authentic2_auth_oidc/urls.py index 7583e37b..61b65428 100644 --- a/src/authentic2_auth_oidc/urls.py +++ b/src/authentic2_auth_oidc/urls.py @@ -1,3 +1,19 @@ +# 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 . + from django.conf.urls import url from . import views diff --git a/src/authentic2_auth_oidc/utils.py b/src/authentic2_auth_oidc/utils.py index 619cee15..7cb419d2 100644 --- a/src/authentic2_auth_oidc/utils.py +++ b/src/authentic2_auth_oidc/utils.py @@ -1,3 +1,19 @@ +# 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 datetime import base64 import json @@ -60,7 +76,7 @@ def base64url_decode(input): def parse_id_token(id_token): try: id_token = str(id_token) - except UnicodeDecodeError as e: + except UnicodeDecodeError: raise ValueError('invalid characters in id_token') payload = id_token.split('.') if len(payload) == 5: @@ -73,7 +89,7 @@ def parse_id_token(id_token): raise ValueError('header is not base64 decodable: %s' % e) try: headers = json.loads(headers) - except ValueError as e: + except ValueError: raise ValueError('cannot JSON decode headers') if not isinstance(headers, dict): raise ValueError('JOSE header is not a dict %r' % headers) @@ -250,11 +266,11 @@ def register_issuer(name, issuer=None, openid_configuration=None, verify=True, t old_pk = models.OIDCProvider.objects.get(issuer=openid_configuration['issuer']).pk except models.OIDCProvider.DoesNotExist: old_pk = None - if (set(['RS256', 'RS384', 'RS512']) & - set(openid_configuration['id_token_signing_alg_values_supported'])): + if (set(['RS256', 'RS384', 'RS512']) + & set(openid_configuration['id_token_signing_alg_values_supported'])): idtoken_algo = models.OIDCProvider.ALGO_RSA - elif (set(['HS256', 'HS384', 'HS512']) & - set(openid_configuration['id_token_signing_alg_values_supported'])): + elif (set(['HS256', 'HS384', 'HS512']) + & set(openid_configuration['id_token_signing_alg_values_supported'])): idtoken_algo = models.OIDCProvider.ALGO_HMAC else: raise ValueError(_('no common algorithm found for signing idtokens: %s') % diff --git a/src/authentic2_auth_oidc/views.py b/src/authentic2_auth_oidc/views.py index f416dd71..813fc000 100644 --- a/src/authentic2_auth_oidc/views.py +++ b/src/authentic2_auth_oidc/views.py @@ -1,3 +1,19 @@ +# 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 uuid import logging import json diff --git a/src/authentic2_auth_saml/__init__.py b/src/authentic2_auth_saml/__init__.py index eeeee9f5..2e0c5eeb 100644 --- a/src/authentic2_auth_saml/__init__.py +++ b/src/authentic2_auth_saml/__init__.py @@ -1,3 +1,20 @@ +# 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 . + + class Plugin(object): def get_before_urls(self): from . import urls diff --git a/src/authentic2_auth_saml/adapters.py b/src/authentic2_auth_saml/adapters.py index 3f23fde4..c2c1c200 100644 --- a/src/authentic2_auth_saml/adapters.py +++ b/src/authentic2_auth_saml/adapters.py @@ -1,3 +1,19 @@ +# 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 logging from mellon.adapters import DefaultAdapter diff --git a/src/authentic2_auth_saml/app_settings.py b/src/authentic2_auth_saml/app_settings.py index f4034a0b..adbcab7a 100644 --- a/src/authentic2_auth_saml/app_settings.py +++ b/src/authentic2_auth_saml/app_settings.py @@ -1,3 +1,21 @@ +# 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 sys + class AppSettings(object): '''Thanks django-allauth''' @@ -19,9 +37,6 @@ class AppSettings(object): def enable(self): return self._setting('ENABLE', False) - -import sys - app_settings = AppSettings('A2_AUTH_SAML_') app_settings.__name__ = __name__ sys.modules[__name__] = app_settings diff --git a/src/authentic2_auth_saml/authenticators.py b/src/authentic2_auth_saml/authenticators.py index d1f1ffc4..ebb221ba 100644 --- a/src/authentic2_auth_saml/authenticators.py +++ b/src/authentic2_auth_saml/authenticators.py @@ -1,3 +1,19 @@ +# 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 . + from django.utils.translation import gettext_noop from django.template.loader import render_to_string from django.shortcuts import render diff --git a/src/authentic2_auth_saml/backends.py b/src/authentic2_auth_saml/backends.py index ca448712..8893dcc0 100644 --- a/src/authentic2_auth_saml/backends.py +++ b/src/authentic2_auth_saml/backends.py @@ -1,3 +1,19 @@ +# 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 . + from mellon.backends import SAMLBackend from authentic2.middleware import StoreRequestMiddleware diff --git a/src/authentic2_auth_saml/models.py b/src/authentic2_auth_saml/models.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2_auth_saml/urls.py b/src/authentic2_auth_saml/urls.py index 0bb19614..c60d7b93 100644 --- a/src/authentic2_auth_saml/urls.py +++ b/src/authentic2_auth_saml/urls.py @@ -1,3 +1,19 @@ +# 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 . + from django.conf.urls import url, include urlpatterns = [url(r'^accounts/saml/', include('mellon.urls'))] diff --git a/src/authentic2_auth_saml/views.py b/src/authentic2_auth_saml/views.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authentic2_idp_cas/__init__.py b/src/authentic2_idp_cas/__init__.py index 0993f16f..7e0b93b3 100644 --- a/src/authentic2_idp_cas/__init__.py +++ b/src/authentic2_idp_cas/__init__.py @@ -1,8 +1,24 @@ +# 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 . + from django.template.loader import render_to_string -from django.utils.translation import ugettext_lazy as _ from .constants import SESSION_CAS_LOGOUTS + class Plugin(object): def get_before_urls(self): from . import app_settings @@ -10,10 +26,10 @@ class Plugin(object): from authentic2.decorators import setting_enabled, required return required( - ( - setting_enabled('ENABLE', settings=app_settings), - ), - [url(r'^idp/cas/', include(__name__ + '.urls'))]) + ( + setting_enabled('ENABLE', settings=app_settings), + ), + [url(r'^idp/cas/', include(__name__ + '.urls'))]) def get_apps(self): return [__name__] @@ -30,4 +46,4 @@ class Plugin(object): } content = render_to_string('authentic2_idp_cas/logout_fragment.html', ctx) fragments.append(content) - return fragments \ No newline at end of file + return fragments diff --git a/src/authentic2_idp_cas/admin.py b/src/authentic2_idp_cas/admin.py index 603bab49..97d94480 100644 --- a/src/authentic2_idp_cas/admin.py +++ b/src/authentic2_idp_cas/admin.py @@ -1,3 +1,19 @@ +# 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 . + from django import forms from django.contrib import admin from django.utils.translation import ugettext as _ @@ -8,6 +24,7 @@ from authentic2.admin import CleanupAdminMixin from . import models + class ServiceForm(forms.ModelForm): def __init__(self, *args, **kwargs): super(ServiceForm, self).__init__(*args, **kwargs) @@ -23,14 +40,15 @@ class ServiceForm(forms.ModelForm): model = models.Service fields = '__all__' + class AttributeInlineForm(forms.ModelForm): def __init__(self, *args, **kwargs): service = kwargs.pop('service', None) super(AttributeInlineForm, self).__init__(*args, **kwargs) choices = self.choices({ - 'user': None, - 'request': None, - 'service': service + 'user': None, + 'request': None, + 'service': service }) self.fields['attribute_name'].choices = choices self.fields['attribute_name'].widget = forms.Select(choices=choices) @@ -42,11 +60,12 @@ class AttributeInlineForm(forms.ModelForm): class Meta: model = models.Attribute fields = [ - 'slug', - 'attribute_name', - 'enabled', + 'slug', + 'attribute_name', + 'enabled', ] + class AttributeInlineAdmin(admin.TabularInline): model = models.Attribute form = AttributeInlineForm @@ -60,40 +79,40 @@ class AttributeInlineAdmin(admin.TabularInline): kwargs['form'] = NewForm return super(AttributeInlineAdmin, self).get_formset(request, obj=obj, **kwargs) + class ServiceAdmin(admin.ModelAdmin): form = ServiceForm list_display = ('name', 'ou', 'slug', 'urls', 'identifier_attribute') prepopulated_fields = {"slug": ("name",)} fieldsets = ( - (None, { - 'fields': [ - 'name', - 'slug', - 'ou', - 'urls', - 'identifier_attribute', - 'proxy', - ] - }), - (_('Logout'), { - 'fields': [ - 'logout_url', - 'logout_use_iframe', - 'logout_use_iframe_timeout', - ], - })) + (None, { + 'fields': [ + 'name', + 'slug', + 'ou', + 'urls', + 'identifier_attribute', + 'proxy', + ]}), + (_('Logout'), { + 'fields': [ + 'logout_url', + 'logout_use_iframe', + 'logout_use_iframe_timeout', + ]})) inlines = [AttributeInlineAdmin] + class TicketAdmin(CleanupAdminMixin, admin.ModelAdmin): list_display = ( - 'ticket_id', - 'validity', - 'renew', - 'service', - 'service_url', - 'user', - 'creation', - 'expire' + 'ticket_id', + 'validity', + 'renew', + 'service', + 'service_url', + 'user', + 'creation', + 'expire' ) admin.site.register(models.Service, ServiceAdmin) diff --git a/src/authentic2_idp_cas/app_settings.py b/src/authentic2_idp_cas/app_settings.py index 92da725f..436c0ec3 100644 --- a/src/authentic2_idp_cas/app_settings.py +++ b/src/authentic2_idp_cas/app_settings.py @@ -1,8 +1,27 @@ +# 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 sys + + class AppSettings(object): __DEFAULTS = { - 'ENABLE': False, - # allow do tisable check of pgt url for testing purpose - 'CHECK_PGT_URL': True, + 'ENABLE': False, + # allow do tisable check of pgt url for testing purpose + 'CHECK_PGT_URL': True, } def __init__(self, prefix): @@ -20,7 +39,6 @@ class AppSettings(object): # Ugly? Guido recommends this himself ... # http://mail.python.org/pipermail/python-ideas/2012-May/014969.html -import sys app_settings = AppSettings('A2_IDP_CAS_') app_settings.__name__ = __name__ sys.modules[__name__] = app_settings diff --git a/src/authentic2_idp_cas/constants.py b/src/authentic2_idp_cas/constants.py index 3cfdc939..cdbf117e 100644 --- a/src/authentic2_idp_cas/constants.py +++ b/src/authentic2_idp_cas/constants.py @@ -1,3 +1,19 @@ +# 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 . + # Constants # CAS_NAMESPACE = 'http://www.yale.edu/tp/cas' RENEW_PARAM = 'renew' diff --git a/src/authentic2_idp_cas/managers.py b/src/authentic2_idp_cas/managers.py index 6bc0f220..6182e3cf 100644 --- a/src/authentic2_idp_cas/managers.py +++ b/src/authentic2_idp_cas/managers.py @@ -1,3 +1,19 @@ +# 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 . + from datetime import timedelta from django.db import models @@ -14,8 +30,7 @@ class TicketQuerySet(query.QuerySet): def cleanup(self): '''Delete old tickets''' qs = self.filter(expire__lt=now()) - qs |= self.filter(expire__isnull=True, - creation__lt=now()-timedelta(seconds=300)) + qs |= self.filter(expire__isnull=True, creation__lt=now() - timedelta(seconds=300)) qs.delete() diff --git a/src/authentic2_idp_cas/models.py b/src/authentic2_idp_cas/models.py index 794e06cb..43a192a1 100644 --- a/src/authentic2_idp_cas/models.py +++ b/src/authentic2_idp_cas/models.py @@ -1,3 +1,19 @@ +# 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 . + from django.db import models from django.utils import six from django.utils.translation import ugettext_lazy as _ @@ -6,23 +22,22 @@ from django.core.validators import URLValidator from django.core.exceptions import ValidationError from authentic2.models import LogoutUrlAbstract, Service -from authentic2 import compat from authentic2.utils import check_session_key from . import managers, utils, constants url_validator = URLValidator(schemes=['http', 'https', 'ftp', 'ftps', 'imap', 'imaps', 'sieve', 'smtp', 'smtps', 'ssh']) + @six.python_2_unicode_compatible class Service(LogoutUrlAbstract, Service): - urls = models.TextField(max_length=128, - verbose_name=_('urls')) - identifier_attribute = models.CharField(max_length=64, - verbose_name=_('attribute name'), blank=False) - proxy = models.ManyToManyField('self', - blank=True, - verbose_name=_('proxy'), - help_text=_('services who can request proxy tickets for this service')) + urls = models.TextField(max_length=128, verbose_name=_('urls')) + identifier_attribute = models.CharField(max_length=64, verbose_name=_('attribute name'), blank=False) + proxy = models.ManyToManyField( + 'self', + blank=True, + verbose_name=_('proxy'), + help_text=_('services who can request proxy tickets for this service')) objects = managers.ServiceManager() @@ -66,12 +81,9 @@ class Service(LogoutUrlAbstract, Service): @six.python_2_unicode_compatible class Attribute(models.Model): service = models.ForeignKey(Service, verbose_name=_('service')) - slug = models.SlugField(verbose_name=_('slug')) - attribute_name = models.CharField(max_length=64, - verbose_name=_('attribute name'), blank=False) - enabled = models.BooleanField( - verbose_name=_('enabled'), - default=True) + slug = models.SlugField(verbose_name=_('slug')) + attribute_name = models.CharField(max_length=64, verbose_name=_('attribute name'), blank=False) + enabled = models.BooleanField(verbose_name=_('enabled'), default=True) def __str__(self): return u'%s <- %s' % (self.slug, self.attribute_name) @@ -81,33 +93,25 @@ class Attribute(models.Model): verbose_name_plural = _('CAS attributes') unique_together = (('service', 'slug', 'attribute_name',),) + def make_uuid(): return utils.make_id(constants.SERVICE_TICKET_PREFIX) + @six.python_2_unicode_compatible class Ticket(models.Model): '''Session ticket with a CAS 1.0 or 2.0 consumer''' - - ticket_id = models.CharField(max_length=64, - verbose_name=_('ticket id'), - unique=True, - default=make_uuid) - renew = models.BooleanField(default=False, - verbose_name=_('fresh authentication')) - validity = models.BooleanField(default=False, - verbose_name=_('valid')) - service = models.ForeignKey(Service, verbose_name=_('service')) + ticket_id = models.CharField(max_length=64, verbose_name=_('ticket id'), unique=True, default=make_uuid) + renew = models.BooleanField(default=False, verbose_name=_('fresh authentication')) + validity = models.BooleanField(default=False, verbose_name=_('valid')) + service = models.ForeignKey(Service, verbose_name=_('service')) service_url = models.TextField(verbose_name=_('service URL'), blank=True, default='') - user = models.ForeignKey(compat.user_model_label, max_length=128, - blank=True, null=True, verbose_name=_('user')) - creation = models.DateTimeField(auto_now_add=True, - verbose_name=_('creation')) - expire = models.DateTimeField( - verbose_name=_('expire'), blank=True, null=True) - session_key = models.CharField(max_length=64, db_index=True, blank=True, - verbose_name=_('django session key'), default='') - proxies = models.TextField( - verbose_name=_('proxies'), blank=True, default='') + user = models.ForeignKey('custom_user.User', max_length=128, blank=True, null=True, verbose_name=_('user')) + creation = models.DateTimeField(auto_now_add=True, verbose_name=_('creation')) + expire = models.DateTimeField(verbose_name=_('expire'), blank=True, null=True) + session_key = models.CharField( + max_length=64, db_index=True, blank=True, verbose_name=_('django session key'), default='') + proxies = models.TextField(verbose_name=_('proxies'), blank=True, default='') objects = managers.TicketManager() diff --git a/src/authentic2_idp_cas/urls.py b/src/authentic2_idp_cas/urls.py index 829095d8..c8473b30 100644 --- a/src/authentic2_idp_cas/urls.py +++ b/src/authentic2_idp_cas/urls.py @@ -1,3 +1,19 @@ +# 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 . + from django.conf.urls import url from . import views diff --git a/src/authentic2_idp_cas/utils.py b/src/authentic2_idp_cas/utils.py index e71f9b0d..c2c49f83 100644 --- a/src/authentic2_idp_cas/utils.py +++ b/src/authentic2_idp_cas/utils.py @@ -1,10 +1,27 @@ +# 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 random import string -ALPHABET = string.ascii_letters+string.digits+'-' +ALPHABET = string.ascii_letters + string.digits + '-' + def make_id(prefix='', length=29): '''Generate CAS tickets identifiers''' - l = length-len(prefix) - content = ( random.SystemRandom().choice(ALPHABET) for x in range(l) ) + c = length - len(prefix) + content = (random.SystemRandom().choice(ALPHABET) for x in range(c) ) return prefix + ''.join(content) diff --git a/src/authentic2_idp_cas/views.py b/src/authentic2_idp_cas/views.py index fd70d091..1bfca94c 100644 --- a/src/authentic2_idp_cas/views.py +++ b/src/authentic2_idp_cas/views.py @@ -1,3 +1,19 @@ +# 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 logging from datetime import timedelta from xml.etree import ElementTree as ET @@ -11,8 +27,9 @@ from django.utils import six from django.utils.timezone import now from authentic2.utils import (get_user_from_session_key, make_url, - login_require, find_authentication_event, redirect, normalize_attribute_values, - attribute_values_to_identifier) + login_require, find_authentication_event, + redirect, normalize_attribute_values, + attribute_values_to_identifier) from authentic2.attributes_ng.engine import get_attributes from authentic2.constants import NONCE_FIELD_NAME from authentic2.views import logout as logout_view @@ -20,18 +37,17 @@ from authentic2 import hooks from authentic2_idp_cas.models import Ticket, Service from authentic2_idp_cas.utils import make_id -from authentic2_idp_cas.constants import (SERVICE_PARAM, RENEW_PARAM, GATEWAY_PARAM, - TICKET_PARAM, CANCEL_PARAM, SERVICE_TICKET_PREFIX, - INVALID_REQUEST_ERROR, INVALID_TICKET_SPEC_ERROR, - INVALID_SERVICE_ERROR, INVALID_TICKET_ERROR, - CAS10_VALIDATION_FAILURE, CAS20_VALIDATION_FAILURE, - SERVICE_RESPONSE_ELT, AUTHENTICATION_SUCCESS_ELT, USER_ELT, - PGT_URL_PARAM, PGT_IOU_PARAM, SESSION_CAS_LOGOUTS, - CAS10_VALIDATION_SUCCESS, PGT_ELT, PROXIES_ELT, PROXY_ELT, - PGT_PREFIX, PGT_IOU_PREFIX, PT_PREFIX, TARGET_SERVICE_PARAM, - BAD_PGT_ERROR, INVALID_TARGET_SERVICE_ERROR, PROXY_UNAUTHORIZED_ERROR, - PGT_PARAM, PGT_ID_PARAM, CAS20_PROXY_FAILURE, PROXY_SUCCESS_ELT, - PROXY_TICKET_ELT, INTERNAL_ERROR, CAS_NAMESPACE, ATTRIBUTES_ELT) +from authentic2_idp_cas.constants import ( + SERVICE_PARAM, RENEW_PARAM, GATEWAY_PARAM, TICKET_PARAM, CANCEL_PARAM, + SERVICE_TICKET_PREFIX, INVALID_REQUEST_ERROR, INVALID_TICKET_SPEC_ERROR, + INVALID_SERVICE_ERROR, INVALID_TICKET_ERROR, CAS10_VALIDATION_FAILURE, + CAS20_VALIDATION_FAILURE, SERVICE_RESPONSE_ELT, AUTHENTICATION_SUCCESS_ELT, + USER_ELT, PGT_URL_PARAM, PGT_IOU_PARAM, SESSION_CAS_LOGOUTS, + CAS10_VALIDATION_SUCCESS, PGT_ELT, PROXIES_ELT, PROXY_ELT, PGT_PREFIX, + PGT_IOU_PREFIX, PT_PREFIX, TARGET_SERVICE_PARAM, BAD_PGT_ERROR, + INVALID_TARGET_SERVICE_ERROR, PROXY_UNAUTHORIZED_ERROR, PGT_PARAM, + PGT_ID_PARAM, CAS20_PROXY_FAILURE, PROXY_SUCCESS_ELT, PROXY_TICKET_ELT, + INTERNAL_ERROR, CAS_NAMESPACE, ATTRIBUTES_ELT) from . import app_settings try: @@ -55,8 +71,7 @@ class CasMixin(object): def redirect_to_service(self, request, st): if not st.valid(): - return self.failure(request, st.service_url, 'service ticket id is ' - 'not valid') + return self.failure(request, st.service_url, 'service ticket id is not valid') else: return self.return_ticket(request, st) @@ -70,10 +85,10 @@ class CasMixin(object): st.save() if st.service.logout_url: request.session.setdefault(SESSION_CAS_LOGOUTS, []).append(( - st.service.name, - st.service.get_logout_url(request), - st.service.logout_use_iframe, - st.service.logout_use_iframe_timeout)) + st.service.name, + st.service.get_logout_url(request), + st.service.logout_use_iframe, + st.service.logout_use_iframe_timeout)) def authenticate(self, request, st): ''' @@ -84,8 +99,7 @@ class CasMixin(object): nonce = st.ticket_id next_url = make_url('a2-idp-cas-continue', params={ SERVICE_PARAM: st.service_url, NONCE_FIELD_NAME: nonce}) - return login_require(request, next_url=next_url, - params={NONCE_FIELD_NAME: nonce}) + return login_require(request, next_url=next_url, params={NONCE_FIELD_NAME: nonce}) class LoginView(CasMixin, View): @@ -102,8 +116,7 @@ class LoginView(CasMixin, View): if not model: return self.failure(request, service, 'service unknown') if renew and gateway: - return self.failure(request, service, 'renew and gateway cannot be requested ' - 'at the same time') + return self.failure(request, service, 'renew and gateway cannot be requested at the same time') hooks.call_hooks('event', name='sso-request', service=model) @@ -113,8 +126,7 @@ class LoginView(CasMixin, View): service = service[:4096] st.service_url = service st.renew = renew - self.logger.debug('login request from %r renew: %s gateway: %s', - service, renew, gateway) + self.logger.debug('login request from %r renew: %s gateway: %s', service, renew, gateway) if self.must_authenticate(request, renew, gateway): st.save() return self.authenticate(request, st) @@ -175,7 +187,7 @@ class ContinueView(CasMixin, View): if st.valid(): hooks.call_hooks('event', name='sso-success', service=st.service, user=st.user) return redirect(request, service, params={'ticket': st.ticket_id}) - # Should not happen + # Should not happen assert False @@ -214,14 +226,12 @@ class ValidateBaseView(CasMixin, View): attributes = self.get_attributes(request, st) if st.service.identifier_attribute not in attributes: self.logger.error('unable to compute an identifier for user %r and service %s', - six.text_type(st.user), st.service_url) + six.text_type(st.user), st.service_url) return self.validation_failure(request, service, INTERNAL_ERROR) # Compute user identifier - identifier = attribute_values_to_identifier( - attributes[st.service.identifier_attribute]) + identifier = attribute_values_to_identifier(attributes[st.service.identifier_attribute]) return self.validation_success(request, st, identifier) - except: - raise + except Exception: self.logger.exception('internal server error') return self.validation_failure(request, service, INTERNAL_ERROR) @@ -230,8 +240,8 @@ class ValidateBaseView(CasMixin, View): if not hasattr(st, 'attributes'): wanted_attributes = st.service.get_wanted_attributes() user = get_user_from_session_key(st.session_key) - assert user.pk # not an annymous user - assert st.user_id == user.pk # session user matches ticket user + assert user.pk # not an annymous user + assert st.user_id == user.pk # session user matches ticket user st.attributes = get_attributes({ 'request': request, 'user': user, @@ -245,19 +255,17 @@ class ValidateBaseView(CasMixin, View): return self.real_validation_failure(request, service, code) def validation_success(self, request, st, identifier): - self.logger.info('validation success service: %r ticket: %s ' - 'user: %r identifier: %r', st.service_url, st.ticket_id, six.text_type(st.user), identifier) + self.logger.info('validation success service: %r ticket: %s user: %r identifier: %r', + st.service_url, st.ticket_id, six.text_type(st.user), identifier) return self.real_validation_success(request, st, identifier) class ValidateView(ValidateBaseView): def real_validation_failure(self, request, service, code): - return HttpResponse(CAS10_VALIDATION_FAILURE, - content_type='text/plain') + return HttpResponse(CAS10_VALIDATION_FAILURE, content_type='text/plain') def real_validation_success(self, request, st, identifier): - return HttpResponse(CAS10_VALIDATION_SUCCESS % identifier, - content_type='text/plain') + return HttpResponse(CAS10_VALIDATION_SUCCESS % identifier, content_type='text/plain') class ServiceValidateView(ValidateBaseView): @@ -265,11 +273,10 @@ class ServiceValidateView(ValidateBaseView): def real_validation_failure(self, request, service, code, message=''): message = message or self.get_cas20_error_message(code) - return HttpResponse(CAS20_VALIDATION_FAILURE % (code, message), - content_type='text/xml') + return HttpResponse(CAS20_VALIDATION_FAILURE % (code, message), content_type='text/xml') def get_cas20_error_message(self, code): - return '' # FIXME + return '' # FIXME def real_validation_success(self, request, st, identifier): root = ET.Element(SERVICE_RESPONSE_ELT) @@ -278,8 +285,7 @@ class ServiceValidateView(ValidateBaseView): user.text = six.text_type(identifier) self.provision_pgt(request, st, success) self.provision_attributes(request, st, success) - return HttpResponse(ET.tostring(root, encoding='utf-8'), - content_type='text/xml') + return HttpResponse(ET.tostring(root, encoding='utf-8'), content_type='text/xml') def provision_attributes(self, request, st, success): '''Add attributes to the CAS 2.0 ticket''' @@ -300,7 +306,6 @@ class ServiceValidateView(ValidateBaseView): attribute_elt = ET.SubElement(attributes_elt, '{%s}%s' % (CAS_NAMESPACE, key)) attribute_elt.text = six.text_type(value) - def provision_pgt(self, request, st, success): '''Provision a PGT ticket if requested ''' @@ -312,33 +317,32 @@ class ServiceValidateView(ValidateBaseView): return # PGT URL must be declared if not st.service.match_service(pgt_url): - self.logger.warning('pgtUrl %r does not match service %r', - pgt_url, st.service.slug) + self.logger.warning('pgtUrl %r does not match service %r', pgt_url, st.service.slug) pgt = make_id(PGT_PREFIX) pgt_iou = make_id(PGT_IOU_PREFIX) # Skip PGT_URL check for testing purpose # instead store PGT_IOU / PGT association in session if app_settings.CHECK_PGT_URL: - response = requests.get(pgt_url, params={ - PGT_ID_PARAM: pgt, - PGT_IOU_PARAM: pgt_iou}) + response = requests.get(pgt_url, + params={ + PGT_ID_PARAM: pgt, + PGT_IOU_PARAM: pgt_iou}) if response.status_code != 200: - self.logger.warning('pgtUrl %r returned non 200 code: %d', - pgt_url, response.status_code) + self.logger.warning('pgtUrl %r returned non 200 code: %d', pgt_url, response.status_code) return else: request.session[pgt_iou] = pgt proxies = ('%s %s' % (pgt_url, st.proxies)).strip() # Save the PGT ticket Ticket.objects.create( - ticket_id=pgt, - expire=None, - service=st.service, - service_url=st.service_url, - validity=True, - user=st.user, - session_key=st.session_key, - proxies=proxies) + ticket_id=pgt, + expire=None, + service=st.service, + service_url=st.service_url, + validity=True, + user=st.user, + session_key=st.session_key, + proxies=proxies) user = ET.SubElement(success, PGT_ELT) user.text = pgt_iou if self.add_proxies: @@ -350,23 +354,22 @@ class ServiceValidateView(ValidateBaseView): class ProxyView(View): http_method_names = ['get'] - + def get(self, request): pgt = request.GET.get(PGT_PARAM) target_service_url = request.GET.get(TARGET_SERVICE_PARAM) if not pgt or not target_service_url: return self.validation_failure(INVALID_REQUEST_ERROR, - "'pgt' and 'targetService' parameters are both required") + "'pgt' and 'targetService' parameters are both required") if not pgt.startswith(PGT_PREFIX): return self.validation_failure(BAD_PGT_ERROR, - 'a proxy granting ticket must start with PGT-') + 'a proxy granting ticket must start with PGT-') try: pgt = Ticket.objects.get(ticket_id=pgt) except Ticket.DoesNotExist: pgt = None if pgt is None: - return self.validation_failure(BAD_PGT_ERROR, 'pgt does not ' - 'exist') + return self.validation_failure(BAD_PGT_ERROR, 'pgt does not exist') if not pgt.valid(): pgt.delete() return self.validation_failure(BAD_PGT_ERROR, 'session has expired') @@ -374,17 +377,15 @@ class ProxyView(View): # No target service exists for this url, maybe the URL is missing from # the urls field if not target_service: - return self.validation_failure(INVALID_TARGET_SERVICE_ERROR, - 'target service is invalid') + return self.validation_failure(INVALID_TARGET_SERVICE_ERROR, 'target service is invalid') # Verify that the requested service is authorized to get proxy tickets # for the target service if not target_service.proxy.filter(pk=pgt.service_id).exists(): - return self.validation_failure(PROXY_UNAUTHORIZED_ERROR, - 'proxying to the target service is forbidden') + return self.validation_failure(PROXY_UNAUTHORIZED_ERROR, 'proxying to the target service is forbidden') pt = Ticket.objects.create( ticket_id=make_id(PT_PREFIX), validity=True, - expire=now()+timedelta(seconds=60), + expire=now() + timedelta(seconds=60), service=target_service, service_url=target_service_url, user=pgt.user, @@ -393,16 +394,14 @@ class ProxyView(View): return self.validation_success(request, pt) def validation_failure(self, code, reason): - return HttpResponse(CAS20_PROXY_FAILURE % (code, reason), - content_type='text/xml') + return HttpResponse(CAS20_PROXY_FAILURE % (code, reason), content_type='text/xml') def validation_success(self, request, pt): root = ET.Element(SERVICE_RESPONSE_ELT) success = ET.SubElement(root, PROXY_SUCCESS_ELT) proxy_ticket = ET.SubElement(success, PROXY_TICKET_ELT) proxy_ticket.text = pt.ticket_id - return HttpResponse(ET.tostring(root, encoding='utf-8'), - content_type='text/xml') + return HttpResponse(ET.tostring(root, encoding='utf-8'), content_type='text/xml') class ProxyValidateView(ServiceValidateView): @@ -420,8 +419,7 @@ class LogoutView(View): if referrer: model = Service.objects.for_service(referrer) if model: - return logout_view(request, next_url=next_url, - check_referer=False, do_local=False) + return logout_view(request, next_url=next_url, check_referer=False, do_local=False) return redirect(request, next_url) login = LoginView.as_view() diff --git a/src/authentic2_idp_oidc/__init__.py b/src/authentic2_idp_oidc/__init__.py index 22189cfe..fb1c8fd6 100644 --- a/src/authentic2_idp_oidc/__init__.py +++ b/src/authentic2_idp_oidc/__init__.py @@ -1,5 +1,20 @@ +# 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 . + from django.template.loader import render_to_string -from django.utils.translation import ugettext_lazy as _ default_app_config = 'authentic2_idp_oidc.apps.AppConfig' diff --git a/src/authentic2_idp_oidc/admin.py b/src/authentic2_idp_oidc/admin.py index 924299d6..aa7f0087 100644 --- a/src/authentic2_idp_oidc/admin.py +++ b/src/authentic2_idp_oidc/admin.py @@ -1,3 +1,19 @@ +# 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 . + from django import forms from django.contrib import admin from django.utils.functional import curry diff --git a/src/authentic2_idp_oidc/app_settings.py b/src/authentic2_idp_oidc/app_settings.py index a38d0a88..6449ec3b 100644 --- a/src/authentic2_idp_oidc/app_settings.py +++ b/src/authentic2_idp_oidc/app_settings.py @@ -1,3 +1,22 @@ +# 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 sys + + class AppSettings(object): '''Thanks django-allauth''' __SENTINEL = object() @@ -34,9 +53,6 @@ class AppSettings(object): def IDTOKEN_DURATION(self): return self._setting('IDTOKEN_DURATION', 30) - -import sys - app_settings = AppSettings('A2_IDP_OIDC_') app_settings.__name__ = __name__ sys.modules[__name__] = app_settings diff --git a/src/authentic2_idp_oidc/apps.py b/src/authentic2_idp_oidc/apps.py index c3c56367..0813c29b 100644 --- a/src/authentic2_idp_oidc/apps.py +++ b/src/authentic2_idp_oidc/apps.py @@ -1,5 +1,5 @@ -# authentic2_idp_oidc - Authentic2 OIDC IdP plugin -# Copyright (C) 2017 Entr'ouvert +# 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 @@ -17,8 +17,6 @@ import django.apps from django.utils.encoding import smart_bytes -from rest_framework.exceptions import APIException - class AppConfig(django.apps.AppConfig): name = 'authentic2_idp_oidc' diff --git a/src/authentic2_idp_oidc/managers.py b/src/authentic2_idp_oidc/managers.py index 1b33bae3..6f6fef62 100644 --- a/src/authentic2_idp_oidc/managers.py +++ b/src/authentic2_idp_oidc/managers.py @@ -1,3 +1,19 @@ +# 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 . + from django.db.models import Manager from django.utils.timezone import now diff --git a/src/authentic2_idp_oidc/models.py b/src/authentic2_idp_oidc/models.py index 943b1f6d..d6d52d32 100644 --- a/src/authentic2_idp_oidc/models.py +++ b/src/authentic2_idp_oidc/models.py @@ -1,8 +1,23 @@ +# 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 uuid from importlib import import_module from django.db import models -from django.contrib.contenttypes.models import ContentType from django.core.validators import URLValidator from django.core.exceptions import ValidationError, ImproperlyConfigured from django.utils.translation import ugettext_lazy as _ @@ -11,7 +26,7 @@ from django.utils import six from django.utils.timezone import now from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation -from authentic2.managers import GenericManager +from authentic2.a2_rbac.models import OrganizationalUnit from authentic2.models import Service from authentic2.utils import to_iter @@ -305,7 +320,6 @@ class OIDCAccessToken(models.Model): self.scopes) # Add generic field to a2_rbac.OrganizationalUnit -from authentic2.a2_rbac.models import OrganizationalUnit GenericRelation('authentic2_idp_oidc.OIDCAuthorization', content_type_field='client_ct', object_id_field='client_id').contribute_to_class( diff --git a/src/authentic2_idp_oidc/urls.py b/src/authentic2_idp_oidc/urls.py index 51143bf8..b5f0ffae 100644 --- a/src/authentic2_idp_oidc/urls.py +++ b/src/authentic2_idp_oidc/urls.py @@ -1,3 +1,19 @@ +# 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 . + from django.conf.urls import url from . import views diff --git a/src/authentic2_idp_oidc/utils.py b/src/authentic2_idp_oidc/utils.py index 640c868a..9599f06c 100644 --- a/src/authentic2_idp_oidc/utils.py +++ b/src/authentic2_idp_oidc/utils.py @@ -1,3 +1,19 @@ +# 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 json import hashlib import base64 @@ -10,7 +26,6 @@ from django.core.exceptions import ImproperlyConfigured from django.conf import settings from django.utils import six from django.utils.encoding import force_bytes, force_text -from django.utils import six from django.utils.six.moves.urllib import parse as urlparse from authentic2 import hooks, crypto diff --git a/src/authentic2_idp_oidc/views.py b/src/authentic2_idp_oidc/views.py index f3121596..b28973e8 100644 --- a/src/authentic2_idp_oidc/views.py +++ b/src/authentic2_idp_oidc/views.py @@ -1,3 +1,19 @@ +# 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 logging import datetime import json @@ -392,8 +408,8 @@ def token(request, *args, **kwargs): expired=oidc_code.created + datetime.timedelta(seconds=expires_in)) start = now() acr = '0' - if (oidc_code.nonce is not None and last_authentication_event(session=oidc_code.session).get('nonce') == - oidc_code.nonce): + if (oidc_code.nonce is not None + and last_authentication_event(session=oidc_code.session).get('nonce') == oidc_code.nonce): acr = '1' # prefill id_token with user info id_token = utils.create_user_info( diff --git a/src/authentic2_provisionning_ldap/management/commands/provision.py b/src/authentic2_provisionning_ldap/management/commands/provision.py index e5f1eed2..ee664966 100644 --- a/src/authentic2_provisionning_ldap/management/commands/provision.py +++ b/src/authentic2_provisionning_ldap/management/commands/provision.py @@ -1,3 +1,19 @@ +# 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 . + from __future__ import print_function try: @@ -8,21 +24,22 @@ except ImportError: ldap = None from ldaptools import paged +from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand from django.utils import six from authentic2.attributes_ng.engine import get_attributes -from authentic2 import compat, utils +from authentic2 import utils from authentic2_provisionning_ldap import app_settings +User = get_user_model() ADD = 1 REPLACE = 2 DELETE = 3 - class Command(BaseCommand): can_import_django_settings = True output_transaction = True @@ -40,8 +57,7 @@ class Command(BaseCommand): def handle(self, *args, **options): ressources = app_settings.RESSOURCES if options['target_resource']: - ressources = [ressource for ressource in ressources - if ressource.get('name') in options['target_resource']] + ressources = [ressource for ressource in ressources if ressource.get('name') in options['target_resource']] for ressource in ressources: self.sync_ressource(ressource, **options) @@ -56,7 +72,7 @@ class Command(BaseCommand): if isinstance(value, six.text_type): value = value.encode('utf-8') elif isinstance(value, str): - pass # must be well encoded + pass # must be well encoded else: raise NotImplementedError('value %r not supported' % value) ldap_values.append(value) @@ -73,7 +89,7 @@ class Command(BaseCommand): (rdn_attributes, ldap_attributes) rdn.append((ldap_attribute, values[0], 1)) dn = [rdn] + dn - return dn2str(dn), ('&', [(a,b) for a, b, c in rdn]) + return dn2str(dn), ('&', [(a, b) for a, b, c in rdn]) def format_filter(self, filters): if isinstance(filters, six.string_types): @@ -88,8 +104,7 @@ class Command(BaseCommand): verbosity = int(options['verbosity']) fake = options['fake'] # FIXME: Check ressource well formedness - conn = paged.PagedLDAPObject(ressource['url'], retry_max=10, - retry_delay=2) + conn = paged.PagedLDAPObject(ressource['url'], retry_max=10, retry_delay=2) base_dn = ressource['base_dn'] use_tls = ressource.get('use_tls') bind_dn = ressource.get('bind_dn') @@ -105,7 +120,6 @@ class Command(BaseCommand): default_ctx = ressource.get('attribute_context', {}) ldap_filter = ressource.get('ldap_filter', '(objectclass=*)') delete = ressource.get('delete', True) - User = compat.get_user_model() qs = User.objects.filter(**ressource.get('a2_filter', {})) todelete = set() user_dns = set() @@ -126,20 +140,19 @@ class Command(BaseCommand): self.add_values(ldap_attributes, ldap_attribute, values) for ldap_attribute, fmt_tpls in format_mapping.items(): for fmt_tpl in fmt_tpls: - self.add_values(ldap_attributes, ldap_attribute, - [fmt_tpl.format(**ctx)]) + self.add_values(ldap_attributes, ldap_attribute, [fmt_tpl.format(**ctx)]) dn, filt = self.build_dn_and_filter(ressource, ldap_attributes) user_dns.add(dn) ldap_users[dn] = ldap_attributes filters.append(filt) batch_filter = ldap_filter if filters: - batch_filter = self.format_filter(('&', (batch_filter, ('|', - filters)))) + batch_filter = self.format_filter(('&', (batch_filter, ('|', filters)))) existing_dn = set() - for dn, entry in conn.paged_search_ext_s(base_dn, - ldap.SCOPE_SUBTREE, - batch_filter, list(attributes)): + for dn, entry in conn.paged_search_ext_s( + base_dn, + ldap.SCOPE_SUBTREE, + batch_filter, list(attributes)): entry = utils.to_dict_of_set(utils.lower_keys(entry)) if dn not in ldap_users: todelete.add(dn) @@ -167,8 +180,7 @@ class Command(BaseCommand): if not fake: for x in ldap_users: conn.result() - for dn, entry in conn.paged_search_ext_s(base_dn, - ldap.SCOPE_SUBTREE, ldap_filter): + for dn, entry in conn.paged_search_ext_s(base_dn, ldap.SCOPE_SUBTREE, ldap_filter): # ignore the basedn if dn == base_dn: continue diff --git a/src/authentic2_provisionning_ldap/tests/test_ldap.py b/src/authentic2_provisionning_ldap/tests/test_ldap.py index a1ea800c..39a68ee1 100644 --- a/src/authentic2_provisionning_ldap/tests/test_ldap.py +++ b/src/authentic2_provisionning_ldap/tests/test_ldap.py @@ -1,11 +1,29 @@ +# 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 ldap +from django.contrib.auth import get_user_model from django.test import TestCase from unittest import skipUnless from ldaptools import slapd from django.core.management import call_command -from authentic2 import compat + +User = get_user_model() @skipUnless(slapd.has_slapd(), 'slapd is not installed') @@ -25,6 +43,7 @@ class LDAPBaseTestCase(TestCase): self.slapd.restore() self.slapd.start() + class WhoamiTest(LDAPBaseTestCase): def test_whoami(self): conn = self.slapd.get_connection() @@ -37,6 +56,7 @@ userPassword: admin''') conn.simple_bind_s('uid=admin,o=orga', 'admin') assert conn.whoami_s() == 'dn:uid=admin,o=orga' + class ProvisionTest(LDAPBaseTestCase): def test_ldap_provisionning(self): conn = self.slapd.get_connection() @@ -46,7 +66,7 @@ class ProvisionTest(LDAPBaseTestCase): 'bind_dn': self.slapd.root_bind_dn, 'bind_pw': self.slapd.root_bind_password, 'base_dn': 'o=orga', - 'rdn_attributes': ['uid',], + 'rdn_attributes': ['uid'], 'attribute_mapping': { 'uid': 'django_user_username', 'givenName': 'django_user_first_name', @@ -61,11 +81,11 @@ class ProvisionTest(LDAPBaseTestCase): }, 'ldap_filter': '(objectclass=inetorgperson)', }] - User = compat.get_user_model() - users = [User(username='john.doe%s' % i, - first_name='john', - last_name='doe', - email='john.doe@example.com') for i in range(1000)] + users = [ + User(username='john.doe%s' % i, + first_name='john', + last_name='doe', + email='john.doe@example.com') for i in range(1000)] User.objects.bulk_create(users) self.slapd.add_ldif('''dn: uid=test,o=orga @@ -83,10 +103,10 @@ mail: test''') uid = entry['uid'][0] self.assertTrue(uid.startswith('john.doe')) self.assertEquals(entry, { - 'objectClass': ['inetOrgPerson'], - 'uid': [uid], - 'sn': [users[0].last_name], - 'givenName': [users[0].first_name], - 'cn': ['%s %s' % (users[0].first_name, users[0].last_name)], - 'mail': [users[0].email] + 'objectClass': ['inetOrgPerson'], + 'uid': [uid], + 'sn': [users[0].last_name], + 'givenName': [users[0].first_name], + 'cn': ['%s %s' % (users[0].first_name, users[0].last_name)], + 'mail': [users[0].email] }) diff --git a/src/django_rbac/backends.py b/src/django_rbac/backends.py index 030d5d4d..36d4f147 100644 --- a/src/django_rbac/backends.py +++ b/src/django_rbac/backends.py @@ -13,6 +13,7 @@ except ImportError: from . import utils + def get_fk_model(model, fieldname): try: field = model._meta.get_field('ou') diff --git a/src/django_rbac/context_processors.py b/src/django_rbac/context_processors.py index a9528cca..424e6b39 100644 --- a/src/django_rbac/context_processors.py +++ b/src/django_rbac/context_processors.py @@ -3,6 +3,7 @@ from django.contrib.auth.context_processors import PermWrapper as AuthPermWrapper + class PermAnyLookupDict(object): def __init__(self, user, app_label): self.user = user @@ -18,6 +19,7 @@ class PermAnyLookupDict(object): def __bool__(self): raise TypeError('PermAnyLookupDict has not boolean value') + class PermAnyWrapper(object): def __init__(self, user): self.user = user @@ -32,15 +34,17 @@ class PermAnyWrapper(object): def __bool__(self): raise TypeError('PermAnyWrapper has not boolean value') - def __nonzero__(self): # Python 2 compatibility + def __nonzero__(self): # Python 2 compatibility return type(self).__bool__(self) + class PermWrapper(AuthPermWrapper): def __getitem__(self, app_label): if app_label == 'any': return PermAnyWrapper(self.user) return super(PermWrapper, self).__getitem__(app_label) + def auth(request): """ Returns context variables required by apps that use Django's authentication diff --git a/src/django_rbac/managers.py b/src/django_rbac/managers.py index 56236755..e460ec04 100644 --- a/src/django_rbac/managers.py +++ b/src/django_rbac/managers.py @@ -220,7 +220,7 @@ def defer_update_transitive_closure(): RoleParentingManager.tls.DO_UPDATE_CLOSURE = False try: yield - except: + except Exception: raise else: if RoleParentingManager.tls.CLOSURE_UPDATED: diff --git a/src/django_rbac/models.py b/src/django_rbac/models.py index d50fd83a..4e1e30af 100644 --- a/src/django_rbac/models.py +++ b/src/django_rbac/models.py @@ -8,12 +8,10 @@ from django.db import models from django.conf import settings from django.db.models.query import Q, Prefetch try: - from django.contrib.contenttypes.fields import GenericForeignKey, \ - GenericRelation + from django.contrib.contenttypes.fields import GenericForeignKey except ImportError: # Django < 1.8 - from django.contrib.contenttypes.generic import GenericForeignKey, \ - GenericRelation + from django.contrib.contenttypes.generic import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.contrib.auth import get_user_model from django.contrib.auth.models import Group, _user_get_all_permissions, \ @@ -147,10 +145,11 @@ class PermissionAbstractBase(models.Model): objects = managers.PermissionManager() def natural_key(self): - return [self.operation.slug, self.ou and - self.ou.natural_key(), - self.target and self.target_ct.natural_key(), - self.target and self.target.natural_key()] + return [ + self.operation.slug, + self.ou and self.ou.natural_key(), + self.target and self.target_ct.natural_key(), + self.target and self.target.natural_key()] def export_json(self): return { @@ -232,10 +231,8 @@ class RoleAbstractBase(AbstractOrganizationalUnitScopedBase, AbstractBase): prefetch = Prefetch('roles', queryset=self.__class__.objects.filter(pk=self.pk), to_attr='direct') - return User.objects.filter(Q(roles=self) | - Q(roles__parent_relation__parent=self)) \ - .distinct() \ - .prefetch_related(prefetch) + return User.objects.filter( + Q(roles=self) | Q(roles__parent_relation__parent=self)).distinct().prefetch_related(prefetch) def is_direct(self): if hasattr(self, 'direct'): diff --git a/src/django_rbac/test_settings.py b/src/django_rbac/test_settings.py index c886c6d1..c6a520ac 100644 --- a/src/django_rbac/test_settings.py +++ b/src/django_rbac/test_settings.py @@ -1,8 +1,10 @@ +import os + from django.conf import global_settings MIDDLEWARE_CLASSES = global_settings.MIDDLEWARE_CLASSES -SECRET_KEY='whatever' +SECRET_KEY = 'whatever' DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', @@ -11,12 +13,11 @@ DATABASES = { } INSTALLED_APPS = ( - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django_rbac', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django_rbac', ) -import os ALLOWED_HOSTS = [] diff --git a/src/django_rbac/utils.py b/src/django_rbac/utils.py index 70e0c14c..08f0dc00 100644 --- a/src/django_rbac/utils.py +++ b/src/django_rbac/utils.py @@ -1,5 +1,5 @@ -from authentic2.utils import get_hex_uuid -from authentic2.decorators import GlobalCache +import uuid + from django.conf import settings from django.apps import apps from django.utils import six @@ -13,6 +13,11 @@ DEFAULT_MODELS = { constants.RBAC_PERMISSION_MODEL_SETTING: 'django_rbac.Permission', } + +def get_hex_uuid(): + return uuid.uuid4().hex + + def get_swapped_model_name(setting): '''Return a model qualified name given a setting name containing the qualified name of the model, useful to retrieve swappable models @@ -22,6 +27,7 @@ def get_swapped_model_name(setting): setattr(settings, setting, DEFAULT_MODELS[setting]) return getattr(settings, setting) + def get_swapped_model(setting): '''Return a model given a setting name containing the qualified name of the model, useful to retrieve swappable models. @@ -29,34 +35,42 @@ def get_swapped_model(setting): app, model_name = get_swapped_model_name(setting).rsplit('.', 1) return apps.get_model(app, model_name) + def get_role_model_name(): '''Returns the currently configured role model''' return get_swapped_model_name(constants.RBAC_ROLE_MODEL_SETTING) + def get_ou_model_name(): '''Returns the currently configured organizational unit model''' return get_swapped_model_name(constants.RBAC_OU_MODEL_SETTING) + def get_role_parenting_model_name(): '''Returns the currently configured role parenting model''' return get_swapped_model_name(constants.RBAC_ROLE_PARENTING_MODEL_SETTING) + def get_permission_model_name(): '''Returns the currently configured permission model''' return get_swapped_model_name(constants.RBAC_PERMISSION_MODEL_SETTING) + def get_role_model(): '''Returns the currently configured role model''' return get_swapped_model(constants.RBAC_ROLE_MODEL_SETTING) + def get_ou_model(): '''Returns the currently configured organizational unit model''' return get_swapped_model(constants.RBAC_OU_MODEL_SETTING) + def get_role_parenting_model(): '''Returns the currently configured role parenting model''' return get_swapped_model(constants.RBAC_ROLE_PARENTING_MODEL_SETTING) + def get_permission_model(): '''Returns the currently configured permission model''' return get_swapped_model(constants.RBAC_PERMISSION_MODEL_SETTING) diff --git a/tests/cache_urls.py b/tests/cache_urls.py index 880d8256..03486714 100644 --- a/tests/cache_urls.py +++ b/tests/cache_urls.py @@ -1,3 +1,19 @@ +# 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 . + from django.conf.urls import url from django.http import HttpResponse diff --git a/tests/conftest.py b/tests/conftest.py index d18add5d..c5ecca05 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,20 @@ # -*- coding: utf-8 -*- +# 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 pytest import mock diff --git a/tests/settings.py b/tests/settings.py index f6058d98..43402230 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,3 +1,19 @@ +# 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 os # use a faster hasing scheme for passwords diff --git a/tests/test_a2_rbac.py b/tests/test_a2_rbac.py index a5069603..595633f5 100644 --- a/tests/test_a2_rbac.py +++ b/tests/test_a2_rbac.py @@ -1,3 +1,19 @@ +# 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 pytest from django.contrib.contenttypes.models import ContentType diff --git a/tests/test_admin.py b/tests/test_admin.py index 90955bd8..cc923ff5 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -1,4 +1,20 @@ # -*- coding: utf-8 -*- +# 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 . + from authentic2.custom_user.models import User from authentic2.models import Attribute diff --git a/tests/test_all.py b/tests/test_all.py index 89cbb557..1d1f37a9 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -1,4 +1,20 @@ # -*- coding: utf-8 -*- +# 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 json import base64 diff --git a/tests/test_api.py b/tests/test_api.py index d7c6ce4a..013c1b8d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,20 @@ # -*- coding: utf-8 -*- +# 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 json import pytest diff --git a/tests/test_attribute_kinds.py b/tests/test_attribute_kinds.py index 58238d08..d9198b41 100644 --- a/tests/test_attribute_kinds.py +++ b/tests/test_attribute_kinds.py @@ -1,4 +1,20 @@ # -*- coding: utf-8 -*- +# 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 datetime import os diff --git a/tests/test_auth_oidc.py b/tests/test_auth_oidc.py index 4e0bf8d4..d439b324 100644 --- a/tests/test_auth_oidc.py +++ b/tests/test_auth_oidc.py @@ -1,4 +1,20 @@ # -*- coding: utf-8 -*- +# 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 datetime import os import pytest diff --git a/tests/test_auth_saml.py b/tests/test_auth_saml.py index cd9618e8..fb5477c8 100644 --- a/tests/test_auth_saml.py +++ b/tests/test_auth_saml.py @@ -1,3 +1,19 @@ +# 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 pytest from django.contrib.auth import get_user_model diff --git a/tests/test_backends.py b/tests/test_backends.py index bb6825e7..419b6c76 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -1,3 +1,19 @@ +# 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 . + from django.contrib.auth import authenticate from authentic2.backends import is_user_authenticable diff --git a/tests/test_cas.py b/tests/test_cas.py index 04fbc2c6..eb477313 100644 --- a/tests/test_cas.py +++ b/tests/test_cas.py @@ -1,9 +1,25 @@ +# 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 . + +from django.contrib.auth import get_user_model from django.test.client import RequestFactory, Client from django.test.utils import override_settings from django.utils.six.moves.urllib import parse as urlparse -from authentic2.compat import get_user_model from authentic2_idp_cas.models import Ticket, Service, Attribute from authentic2_idp_cas import constants from authentic2.constants import AUTHENTICATION_EVENTS_SESSION_KEY, NONCE_FIELD_NAME @@ -16,6 +32,9 @@ CAS_NAMESPACES = { 'cas': constants.CAS_NAMESPACE, } +User = get_user_model() +Role = get_role_model() + @override_settings(A2_IDP_CAS_ENABLE=True) class CasTests(Authentic2TestCase): @@ -33,10 +52,7 @@ class CasTests(Authentic2TestCase): SERVICE2_URL = 'https://casclient2.com/service/' PGT_URL = 'https://casclient.con/pgt/' - def setUp(self): - User = get_user_model() - Role = get_role_model() self.user = User.objects.create_user(self.LOGIN, password=self.PASSWORD, email=self.EMAIL, first_name=self.FIRST_NAME, last_name=self.LAST_NAME) @@ -100,7 +116,7 @@ class CasTests(Authentic2TestCase): client = Client() service = self.service service.add_authorized_role(self.authorized_service) - get_user_model().objects.get(username=self.LOGIN).roles.add(self.authorized_service) + User.objects.get(username=self.LOGIN).roles.add(self.authorized_service) assert service.authorized_roles.exists() is True response = client.get('/idp/cas/login', {constants.SERVICE_PARAM: self.URL}) location = response['Location'] diff --git a/tests/test_change_email.py b/tests/test_change_email.py index 1426b265..55b90947 100644 --- a/tests/test_change_email.py +++ b/tests/test_change_email.py @@ -1,3 +1,19 @@ +# 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 utils diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py index 6e70b558..933879ec 100644 --- a/tests/test_cleanup.py +++ b/tests/test_cleanup.py @@ -1,3 +1,19 @@ +# 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 datetime from authentic2.models import DeletedUser diff --git a/tests/test_commands.py b/tests/test_commands.py index f0940eae..e0476976 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,3 +1,19 @@ +# 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 datetime import importlib import json diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py index d0245716..dd3424b8 100644 --- a/tests/test_concurrency.py +++ b/tests/test_concurrency.py @@ -1,3 +1,19 @@ +# 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 . + from django.db import connection from authentic2.models import Attribute, AttributeValue diff --git a/tests/test_crypto.py b/tests/test_crypto.py index 1db6c335..fef902cb 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -1,3 +1,19 @@ +# 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 random import uuid import time diff --git a/tests/test_custom_user.py b/tests/test_custom_user.py index f08e242f..0496d637 100644 --- a/tests/test_custom_user.py +++ b/tests/test_custom_user.py @@ -1,3 +1,19 @@ +# 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 . + from django.test import TestCase from django.contrib.contenttypes.models import ContentType from django.contrib.auth import get_user_model diff --git a/tests/test_customfields.py b/tests/test_customfields.py index fb3eaac7..5e40031b 100644 --- a/tests/test_customfields.py +++ b/tests/test_customfields.py @@ -1,3 +1,19 @@ +# 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 pytest from authentic2.saml.models import KeyValue, NAME_ID_FORMATS_CHOICES, SPOptionsIdPPolicy diff --git a/tests/test_data_transfer.py b/tests/test_data_transfer.py index 779396ef..43543a3f 100644 --- a/tests/test_data_transfer.py +++ b/tests/test_data_transfer.py @@ -1,3 +1,19 @@ +# 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 . + from django_rbac.utils import get_role_model, get_ou_model import pytest diff --git a/tests/test_hashers.py b/tests/test_hashers.py index 53d2f697..63827d67 100644 --- a/tests/test_hashers.py +++ b/tests/test_hashers.py @@ -1,3 +1,19 @@ +# 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 . + from django.contrib.auth.hashers import check_password from authentic2 import hashers diff --git a/tests/test_idp_oidc.py b/tests/test_idp_oidc.py index 64745c63..9a9d05d2 100644 --- a/tests/test_idp_oidc.py +++ b/tests/test_idp_oidc.py @@ -1,3 +1,19 @@ +# 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 base64 import json import datetime diff --git a/tests/test_idp_saml2.py b/tests/test_idp_saml2.py index 8e84d9af..291422f9 100644 --- a/tests/test_idp_saml2.py +++ b/tests/test_idp_saml2.py @@ -1,3 +1,19 @@ +# 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 re import datetime import base64 diff --git a/tests/test_import_export_site_cmd.py b/tests/test_import_export_site_cmd.py index de1fc6a0..cae58f9b 100644 --- a/tests/test_import_export_site_cmd.py +++ b/tests/test_import_export_site_cmd.py @@ -1,3 +1,19 @@ +# 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 random import json diff --git a/tests/test_ldap.py b/tests/test_ldap.py index ef3b979d..cf1396f2 100644 --- a/tests/test_ldap.py +++ b/tests/test_ldap.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# -# Copyright (C) 2017-2019 Entr'ouvert +# 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 diff --git a/tests/test_login.py b/tests/test_login.py index b6312de7..8d18b277 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -1,4 +1,4 @@ -# +# authentic2 - versatile identity manager # Copyright (C) 2010-2019 Entr'ouvert # # This program is free software: you can redistribute it and/or modify it diff --git a/tests/test_manager.py b/tests/test_manager.py index d8ae1b19..04ed5c0d 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -1,4 +1,19 @@ # -*- coding: utf-8 -*- +# 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 pytest import json diff --git a/tests/test_natural_key.py b/tests/test_natural_key.py index dda37e11..79fa51a0 100644 --- a/tests/test_natural_key.py +++ b/tests/test_natural_key.py @@ -1,3 +1,18 @@ +# 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 pytest from django.contrib.contenttypes.models import ContentType diff --git a/tests/test_ou_manager.py b/tests/test_ou_manager.py index 3945434a..1abc6cbf 100644 --- a/tests/test_ou_manager.py +++ b/tests/test_ou_manager.py @@ -1,3 +1,18 @@ +# 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 . from utils import login diff --git a/tests/test_password_reset.py b/tests/test_password_reset.py index b3a1a1bb..362452f9 100644 --- a/tests/test_password_reset.py +++ b/tests/test_password_reset.py @@ -1,3 +1,18 @@ +# 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 . from django.core.urlresolvers import reverse from django.test.utils import override_settings diff --git a/tests/test_profile.py b/tests/test_profile.py index 7c3497dd..8482fa0a 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -1,3 +1,18 @@ +# 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 pytest from django.core.urlresolvers import reverse diff --git a/tests/test_registration.py b/tests/test_registration.py index c4693206..9a334218 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -1,4 +1,19 @@ # -*- coding: utf-8 -*- +# 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 re diff --git a/tests/test_role_manager.py b/tests/test_role_manager.py index 3b0de4d2..1fb8f6d2 100644 --- a/tests/test_role_manager.py +++ b/tests/test_role_manager.py @@ -1,3 +1,18 @@ +# 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 . from utils import login diff --git a/tests/test_user_manager.py b/tests/test_user_manager.py index 257839d4..8ec21e55 100644 --- a/tests/test_user_manager.py +++ b/tests/test_user_manager.py @@ -1,3 +1,18 @@ +# 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 csv from django.core.urlresolvers import reverse diff --git a/tests/test_user_model.py b/tests/test_user_model.py index 954d27c9..afea5ab8 100644 --- a/tests/test_user_model.py +++ b/tests/test_user_model.py @@ -1,5 +1,5 @@ -# authentic2 -# Copyright (C) 2019 Entr'ouvert +# 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 @@ -13,6 +13,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +# authentic2 import pytest diff --git a/tests/test_utils.py b/tests/test_utils.py index ddc78fcb..e0a3e470 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,11 +1,30 @@ +# 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 . +# authentic2 + from django.contrib.auth import authenticate from django.contrib.auth.middleware import AuthenticationMiddleware from django.contrib.sessions.middleware import SessionMiddleware -from authentic2.utils import good_next_url, same_origin, select_next_url, user_can_change_password, login, get_authentication_events +from authentic2.utils import (good_next_url, same_origin, select_next_url, + user_can_change_password, login, + get_authentication_events) -def test_good_next_url(rf, settings): +def test_good_next_url(db, rf, settings): request = rf.get('/', HTTP_HOST='example.net', **{'wsgi.url_scheme': 'https'}) assert good_next_url(request, '/admin/') assert good_next_url(request, '/') @@ -43,7 +62,7 @@ def test_same_origin(): assert same_origin('https://example.com:34/coin/', '//example.com') -def test_select_next_url(rf, settings): +def test_select_next_url(db, rf, settings): request = rf.get('/accounts/register/', data={'next': '/admin/'}) assert select_next_url(request, '/') == '/admin/' request = rf.get('/accounts/register/', data={'next': 'http://example.com/'}) diff --git a/tests/test_views.py b/tests/test_views.py index 3b23d4e6..70757fb4 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,3 +1,20 @@ +# 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 . +# authentic2 + from utils import login import pytest diff --git a/tests/test_widget_datetimepicker.py b/tests/test_widget_datetimepicker.py index 9762833c..e555a922 100644 --- a/tests/test_widget_datetimepicker.py +++ b/tests/test_widget_datetimepicker.py @@ -1,3 +1,20 @@ +# 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 . +# authentic2 + from authentic2.widgets import DateTimeWidget, DateWidget, TimeWidget diff --git a/tests/utils.py b/tests/utils.py index adfd206d..c9f922d7 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,3 +1,20 @@ +# 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 . +# authentic2 + import re import base64 import socket -- 2.20.1