diff --git a/README.rst b/README.rst index c751788..63968e3 100644 --- a/README.rst +++ b/README.rst @@ -131,7 +131,8 @@ First-time logins For first-time logins, there is no RemoteUser object to match the external user ID with a local django user. In this case, users are accepted only if the -user presents a valid invitation. This is because there is no way to safely +user presents a valid invitation (or when using ``TrustedProviderMigrationBackend``, see below). +This is because there is no way to safely match external user ids to local django users. There are two kinds of invitations: invitations with user, and invitations @@ -227,6 +228,16 @@ users are matched by username. On first-time login, a RemoteUser object is created to link the external and local users permanently. +Auto-accepting users +-------------------- + +For some sites we might want to automatically create local users if they log in +from a trusted identity provider. For such sites, enable the +``TrustedProviderMigrationBackend`` and add a ``NENS_AUTH_TRUSTED_PROVIDERS_NEW_USERS`` setting. +The setting contains the list of provider names (as configured in cognito) that we trust to +have correct email addresses. + + Auto-accepting N&S users ------------------------ @@ -261,6 +272,14 @@ There is no check on ``email_verified`` as that turns out to be hard to configure. +Auto-assigning permissions +-------------------------- + +Any user that logs in can automatically be assigned permissions. This can be +implemented in the ``auto_assign(user, claims)`` method of a custom permission class, +which needs to be set on the ``NENS_AUTH_PERMISSION_BACKEND`` setting. + + Bearer tokens (optional) ------------------------ diff --git a/nens_auth_client/backends.py b/nens_auth_client/backends.py index 01b9683..bfe4faf 100644 --- a/nens_auth_client/backends.py +++ b/nens_auth_client/backends.py @@ -1,5 +1,6 @@ from .users import _extract_provider_name from .users import create_remote_user +from .users import create_user from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend @@ -162,7 +163,10 @@ def authenticate(self, request, claims): try: user = UserModel.objects.get(email__iexact=email) except ObjectDoesNotExist: - return + if provider_name in settings.NENS_AUTH_TRUSTED_PROVIDERS_NEW_USERS: + return create_user(claims) + else: + return except MultipleObjectsReturned: raise PermissionDenied(settings.NENS_AUTH_ERROR_USER_MULTIPLE) diff --git a/nens_auth_client/checks.py b/nens_auth_client/checks.py index 18fdbbd..b802d4b 100644 --- a/nens_auth_client/checks.py +++ b/nens_auth_client/checks.py @@ -93,3 +93,15 @@ def check_error_message_formatting(app_configs=None, **kwargs): ) return errors + + +@register() +def check_trusted_providers(app_configs=None, **kwargs): + trusted = set(settings.TRUSTED_PROVIDERS) + trusted_new = set(settings.TRUSTED_PROVIDERS_NEW_USERS) + + if not trusted.issuperset(trusted_new): + return [ + Error("TRUSTED_PROVIDERS must be a superset of TRUSTED_PROVIDERS_NEW_USERS") + ] + return [] diff --git a/nens_auth_client/conf.py b/nens_auth_client/conf.py index 9fcbf38..de757f6 100644 --- a/nens_auth_client/conf.py +++ b/nens_auth_client/conf.py @@ -21,6 +21,7 @@ class NensAuthClientAppConf(AppConf): USERNAME_MAX_LENGTH = 50 TRUSTED_PROVIDERS = [] # providerName's trusted by TrustedProviderMigrationBackend + TRUSTED_PROVIDERS_NEW_USERS = [] # providerName's trusted for creating new users ERROR_USER_DOES_NOT_EXIST = "No user account available for these credentials." ERROR_USER_INACTIVE = "This account was set to inactive." diff --git a/nens_auth_client/models.py b/nens_auth_client/models.py index eb9a3b0..1ec126c 100644 --- a/nens_auth_client/models.py +++ b/nens_auth_client/models.py @@ -1,4 +1,5 @@ # (c) Nelen & Schuurmans. Proprietary, see LICENSE file. +from . import permissions from .signals import invitation_accepted from datetime import timedelta from django.conf import settings @@ -168,7 +169,6 @@ def check_acceptability(self, email=None): return True def accept(self, user, **kwargs): - backend = import_string(settings.NENS_AUTH_PERMISSION_BACKEND)() if self.user_id and self.user_id != user.id: raise PermissionDenied( settings.NENS_AUTH_ERROR_INVITATION_WRONG_USER.format( @@ -177,7 +177,7 @@ def accept(self, user, **kwargs): ) ) try: - result = backend.assign( + result = permissions.assign_permissions( permissions=json.loads(self.permissions), user=user, **kwargs ) except Exception: diff --git a/nens_auth_client/permissions.py b/nens_auth_client/permissions.py index 8b9cf5c..1ae5a80 100644 --- a/nens_auth_client/permissions.py +++ b/nens_auth_client/permissions.py @@ -1,7 +1,9 @@ # (c) Nelen & Schuurmans. Proprietary, see LICENSE file. +from django.conf import settings from django.contrib.auth.models import Permission from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError +from django.utils.module_loading import import_string import logging @@ -72,3 +74,22 @@ def assign(self, permissions, user): "Skipped assigning non-existing permission %s", permission_key ) user.user_permissions.add(*user_permission_objs) + + def auto_assign(self, user, claims): + # assign permissions based on the user's claims + # does nothing by default + return None + + +def get_permission_backend(): + return import_string(settings.NENS_AUTH_PERMISSION_BACKEND)() + + +def assign_permissions(permissions, user, **kwargs): + return get_permission_backend().assign(permissions=permissions, user=user, **kwargs) + + +def auto_assign_permissions(user, claims): + backend = get_permission_backend() + if hasattr(backend, "auto_assign"): + backend.auto_assign(user=user, claims=claims) diff --git a/nens_auth_client/tests/test_authorize_view.py b/nens_auth_client/tests/test_authorize_view.py index 78a491b..3d3afff 100644 --- a/nens_auth_client/tests/test_authorize_view.py +++ b/nens_auth_client/tests/test_authorize_view.py @@ -33,6 +33,11 @@ def invitation_getter(mocker): return manager.select_related.return_value.get +@pytest.fixture +def permissions_m(mocker): + return mocker.patch("nens_auth_client.views.permissions") + + def test_authorize( id_token_generator, auth_req_generator, @@ -40,6 +45,7 @@ def test_authorize( openid_configuration, users_m, login_m, + permissions_m, ): id_token, claims = id_token_generator(testclaim="bar") user = User(username="testuser") @@ -72,6 +78,9 @@ def test_authorize( assert args[0] == claims assert args[1].keys() == {"id_token"} + # check if auto_assign_permissions was called + permissions_m.auto_assign_permissions.assert_called_once_with(user, claims) + def test_authorize_no_user(id_token_generator, auth_req_generator, users_m, login_m): id_token, claims = id_token_generator() diff --git a/nens_auth_client/tests/test_backends.py b/nens_auth_client/tests/test_backends.py index b5a985f..3f6cedf 100644 --- a/nens_auth_client/tests/test_backends.py +++ b/nens_auth_client/tests/test_backends.py @@ -26,6 +26,11 @@ def create_remote_user(mocker): return mocker.patch("nens_auth_client.backends.create_remote_user") +@pytest.fixture +def create_user(mocker): + return mocker.patch("nens_auth_client.backends.create_user") + + def test_remote_user_exists(user_getter): user_getter.return_value = User(username="testuser") @@ -398,3 +403,21 @@ def test_trusted_backend_proper_prerequisites(claims): request=None, claims=claims ) assert user is None + + +def test_trusted_backend_create_new_user(user_getter, create_user, settings): + # User does not exist and TRUSTED_PROVIDERS_NEW_USERS set? Create new user. + settings.NENS_AUTH_TRUSTED_PROVIDERS = ["vanrees"] + settings.NENS_AUTH_TRUSTED_PROVIDERS_NEW_USERS = ["vanrees"] + claims = { + "sub": "remote-uid", + "cognito:username": "goede.klant", + "email": "goede.klant@vanrees.org", + "identities": [{"providerName": "vanrees"}], + } + user_getter.side_effect = ObjectDoesNotExist + user = backends.TrustedProviderMigrationBackend().authenticate( + request=None, claims=claims + ) + assert user == create_user.return_value + create_user.assert_called_once_with(claims) diff --git a/nens_auth_client/views.py b/nens_auth_client/views.py index 5c71d44..dae7867 100644 --- a/nens_auth_client/views.py +++ b/nens_auth_client/views.py @@ -1,5 +1,6 @@ # (c) Nelen & Schuurmans. Proprietary, see LICENSE file. # from nens_auth_client import models +from . import permissions from . import users from .backends import RemoteUserBackend from .models import Invitation @@ -207,6 +208,9 @@ def authorize(request): users.update_user(user, claims) users.update_remote_user(claims, tokens) + # Automatically assign permissions based on the user's claims + permissions.auto_assign_permissions(user, claims) + # Log the user in django_auth.login(request, user)