diff --git a/requirements/base.in b/requirements/base.in index 15a7cfd383..eee742a0cc 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -43,6 +43,7 @@ django-timeline-logger django-rich django-csp django-csp-reports +mozilla-django-oidc-db # API libraries djangorestframework diff --git a/requirements/base.txt b/requirements/base.txt index 5deefcdaf5..7069b295d2 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -35,7 +35,10 @@ commonmark==0.9.1 confusable-homoglyphs==3.2.0 # via django-registration cryptography==35.0.0 - # via pyopenssl + # via + # josepy + # mozilla-django-oidc + # pyopenssl cssselect2==0.4.1 # via weasyprint defusedxml==0.7.1 @@ -81,6 +84,8 @@ django==3.2.13 # easy-thumbnails # mail-editor # maykin-django-two-factor-auth + # mozilla-django-oidc + # mozilla-django-oidc-db # zgw-consumers django-admin-index==1.5.0 # via -r requirements/base.in @@ -91,7 +96,9 @@ django-autoslug==1.9.8 django-axes==5.25.0 # via -r requirements/base.in django-better-admin-arrayfield==1.4.2 - # via -r requirements/base.in + # via + # -r requirements/base.in + # mozilla-django-oidc-db django-choices==1.7.2 # via # -r requirements/base.in @@ -169,6 +176,7 @@ django-sniplates==0.7.0 django-solo==1.2.0 # via # -r requirements/base.in + # mozilla-django-oidc-db # zgw-consumers django-timeline-logger==2.0.0 # via -r requirements/base.in @@ -214,7 +222,9 @@ geographiclib==1.52 geopy==2.2.0 # via -r requirements/base.in glom==20.11.0 - # via -r requirements/base.in + # via + # -r requirements/base.in + # mozilla-django-oidc-db html5lib==1.1 # via weasyprint humanfriendly==10.0 @@ -227,6 +237,8 @@ inflection==0.5.1 # via drf-spectacular isodate==0.6.1 # via python3-saml +josepy==1.13.0 + # via mozilla-django-oidc jsonschema==4.1.0 # via drf-spectacular lxml==4.6.3 @@ -242,6 +254,10 @@ markuppy==1.14 # via tablib maykin-django-two-factor-auth==2.0.4 # via -r requirements/base.in +mozilla-django-oidc==2.0.0 + # via mozilla-django-oidc-db +mozilla-django-oidc-db==0.10.0 + # via -r requirements/base.in odfpy==1.4.1 # via tablib openpyxl==3.0.9 @@ -270,6 +286,7 @@ pyjwt==2.3.0 pyopenssl==21.0.0 # via # -r requirements/base.in + # josepy # python3-saml # zgw-consumers pyphen==0.12.0 @@ -308,6 +325,7 @@ requests==2.26.0 # via # django-rosetta # gemma-zds-client + # mozilla-django-oidc # python3-saml # requests-mock # zgw-consumers @@ -373,3 +391,6 @@ zipp==3.6.0 # via importlib-metadata zopfli==0.1.9 # via fonttools + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/requirements/ci.txt b/requirements/ci.txt index 0c4b558b10..b313f3717e 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -68,6 +68,8 @@ cryptography==35.0.0 # via # -c requirements/base.txt # -r requirements/base.txt + # josepy + # mozilla-django-oidc # pyopenssl cssselect==1.1.0 # via pyquery @@ -125,6 +127,8 @@ django==3.2.13 # easy-thumbnails # mail-editor # maykin-django-two-factor-auth + # mozilla-django-oidc + # mozilla-django-oidc-db # zgw-consumers django-admin-index==1.5.0 # via @@ -147,6 +151,7 @@ django-better-admin-arrayfield==1.4.2 # via # -c requirements/base.txt # -r requirements/base.txt + # mozilla-django-oidc-db django-choices==1.7.2 # via # -c requirements/base.txt @@ -292,6 +297,7 @@ django-solo==1.2.0 # via # -c requirements/base.txt # -r requirements/base.txt + # mozilla-django-oidc-db # zgw-consumers django-timeline-logger==2.0.0 # via @@ -392,6 +398,7 @@ glom==20.11.0 # via # -c requirements/base.txt # -r requirements/base.txt + # mozilla-django-oidc-db html5lib==1.1 # via # -c requirements/base.txt @@ -423,6 +430,11 @@ isodate==0.6.1 # python3-saml isort==5.9.3 # via pylint +josepy==1.13.0 + # via + # -c requirements/base.txt + # -r requirements/base.txt + # mozilla-django-oidc jsonschema==4.1.0 # via # -c requirements/base.txt @@ -457,6 +469,15 @@ maykin-django-two-factor-auth==2.0.4 # -r requirements/base.txt mccabe==0.6.1 # via pylint +mozilla-django-oidc==2.0.0 + # via + # -c requirements/base.txt + # -r requirements/base.txt + # mozilla-django-oidc-db +mozilla-django-oidc-db==0.10.0 + # via + # -c requirements/base.txt + # -r requirements/base.txt odfpy==1.4.1 # via # -c requirements/base.txt @@ -522,6 +543,7 @@ pyopenssl==21.0.0 # -c requirements/base.txt # -r requirements/base.txt # -r requirements/test-tools.in + # josepy # python3-saml # zgw-consumers pyphen==0.12.0 @@ -594,6 +616,7 @@ requests==2.26.0 # -r requirements/base.txt # django-rosetta # gemma-zds-client + # mozilla-django-oidc # python3-saml # requests-mock # zgw-consumers diff --git a/requirements/dev.txt b/requirements/dev.txt index 20dcce9c70..daff8b472f 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -85,6 +85,8 @@ cryptography==35.0.0 # via # -c requirements/ci.txt # -r requirements/ci.txt + # josepy + # mozilla-django-oidc # pyopenssl cssselect==1.1.0 # via @@ -150,6 +152,8 @@ django==3.2.13 # easy-thumbnails # mail-editor # maykin-django-two-factor-auth + # mozilla-django-oidc + # mozilla-django-oidc-db # zgw-consumers django-admin-index==1.5.0 # via @@ -172,6 +176,7 @@ django-better-admin-arrayfield==1.4.2 # via # -c requirements/ci.txt # -r requirements/ci.txt + # mozilla-django-oidc-db django-choices==1.7.2 # via # -c requirements/ci.txt @@ -321,6 +326,7 @@ django-solo==1.2.0 # via # -c requirements/ci.txt # -r requirements/ci.txt + # mozilla-django-oidc-db # zgw-consumers django-timeline-logger==2.0.0 # via @@ -437,6 +443,7 @@ glom==20.11.0 # via # -c requirements/ci.txt # -r requirements/ci.txt + # mozilla-django-oidc-db html5lib==1.1 # via # -c requirements/ci.txt @@ -476,6 +483,11 @@ isort==5.9.3 # pylint jinja2==3.0.1 # via sphinx +josepy==1.13.0 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt + # mozilla-django-oidc jsonschema==4.1.0 # via # -c requirements/ci.txt @@ -519,6 +531,15 @@ mccabe==0.6.1 # -r requirements/ci.txt # flake8 # pylint +mozilla-django-oidc==2.0.0 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt + # mozilla-django-oidc-db +mozilla-django-oidc-db==0.10.0 + # via + # -c requirements/ci.txt + # -r requirements/ci.txt mypy-extensions==0.4.3 # via black odfpy==1.4.1 @@ -608,6 +629,7 @@ pyopenssl==21.0.0 # via # -c requirements/ci.txt # -r requirements/ci.txt + # josepy # python3-saml # zgw-consumers pyparsing==2.4.7 @@ -688,6 +710,7 @@ requests==2.26.0 # ddt-api-calls # django-rosetta # gemma-zds-client + # mozilla-django-oidc # python3-saml # requests-mock # sphinx diff --git a/src/open_inwoner/accounts/admin.py b/src/open_inwoner/accounts/admin.py index 3a38084034..1c7337e478 100644 --- a/src/open_inwoner/accounts/admin.py +++ b/src/open_inwoner/accounts/admin.py @@ -20,6 +20,10 @@ class ActionInlineAdmin(UUIDAdminFirstInOrder, admin.StackedInline): @admin.register(User) class _UserAdmin(UserAdmin): hijack_success_url = reverse_lazy("root") + list_display_links = ( + "email", + "first_name", + ) fieldsets = ( (None, {"fields": ("email", "password", "login_type")}), ( @@ -31,6 +35,7 @@ class _UserAdmin(UserAdmin): "contact_type", "bsn", "rsin", + "oidc_id", "birthday", "street", "housenumber", @@ -66,11 +71,12 @@ class _UserAdmin(UserAdmin): }, ), ) - readonly_fields = ("bsn", "rsin", "is_prepopulated") + readonly_fields = ("bsn", "rsin", "is_prepopulated", "oidc_id") list_display = ( "email", "first_name", "last_name", + "login_type", "is_staff", "is_active", "contact_type", diff --git a/src/open_inwoner/accounts/backends.py b/src/open_inwoner/accounts/backends.py index de26a6a75f..3721d47936 100644 --- a/src/open_inwoner/accounts/backends.py +++ b/src/open_inwoner/accounts/backends.py @@ -1,8 +1,17 @@ +import logging + from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend from django.contrib.auth.hashers import check_password from axes.backends import AxesBackend +from mozilla_django_oidc_db.backends import OIDCAuthenticationBackend + +from open_inwoner.utils.hash import generate_email_from_string + +from .choices import LoginTypeChoices + +logger = logging.getLogger(__name__) class UserModelEmailBackend(ModelBackend): @@ -24,3 +33,34 @@ class CustomAxesBackend(AxesBackend): def authenticate(self, request=None, *args, **kwargs): if request: return super().authenticate(request, *args, **kwargs) + + +class CustomOIDCBackend(OIDCAuthenticationBackend): + def create_user(self, claims): + """Return object for a newly created user account.""" + unique_id = self.retrieve_identifier_claim(claims) + + email = generate_email_from_string(unique_id) + if "email" in claims: + email = claims["email"] + + logger.debug("Creating OIDC user: %s", unique_id) + + kwargs = { + "oidc_id": unique_id, + "email": email, + "login_type": LoginTypeChoices.oidc, + } + + user = self.UserModel.objects.create_user(**kwargs) + self.update_user(user, claims) + + return user + + def filter_users_by_claims(self, claims): + """Return all users matching the specified subject.""" + unique_id = self.retrieve_identifier_claim(claims) + + if not unique_id: + return self.UserModel.objects.none() + return self.UserModel.objects.filter(**{"oidc_id__iexact": unique_id}) diff --git a/src/open_inwoner/accounts/choices.py b/src/open_inwoner/accounts/choices.py index ef6d648ede..3d9e501c7c 100644 --- a/src/open_inwoner/accounts/choices.py +++ b/src/open_inwoner/accounts/choices.py @@ -7,6 +7,7 @@ class LoginTypeChoices(DjangoChoices): default = ChoiceItem("default", _("E-mail en Wachtwoord")) digid = ChoiceItem("digid", _("DigiD")) eherkenning = ChoiceItem("eherkenning", _("eHerkenning")) + oidc = ChoiceItem("oidc", _("OpenId connect")) # Created because of a filter that needs to happen. This way the form can take the empty choice and the modal is still filled. diff --git a/src/open_inwoner/accounts/migrations/0044_user_oidc_id.py b/src/open_inwoner/accounts/migrations/0044_user_oidc_id.py new file mode 100644 index 0000000000..7a860c225a --- /dev/null +++ b/src/open_inwoner/accounts/migrations/0044_user_oidc_id.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.13 on 2022-06-22 13:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0043_change_digid_email"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="oidc_id", + field=models.CharField(blank=True, default="", max_length=250), + ), + ] diff --git a/src/open_inwoner/accounts/migrations/0045_alter_user_login_type.py b/src/open_inwoner/accounts/migrations/0045_alter_user_login_type.py new file mode 100644 index 0000000000..ef67cd955e --- /dev/null +++ b/src/open_inwoner/accounts/migrations/0045_alter_user_login_type.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.13 on 2022-06-29 13:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0044_user_oidc_id"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="login_type", + field=models.CharField( + choices=[ + ("default", "E-mail en Wachtwoord"), + ("digid", "DigiD"), + ("eherkenning", "eHerkenning"), + ("oidc", "OpenId connect"), + ], + default="default", + max_length=250, + verbose_name="Login type", + ), + ), + ] diff --git a/src/open_inwoner/accounts/migrations/0046_alter_user_oidc_id.py b/src/open_inwoner/accounts/migrations/0046_alter_user_oidc_id.py new file mode 100644 index 0000000000..245043af59 --- /dev/null +++ b/src/open_inwoner/accounts/migrations/0046_alter_user_oidc_id.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.13 on 2022-07-04 08:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0045_alter_user_login_type"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="oidc_id", + field=models.CharField( + blank=True, + default="", + help_text="This field indicates if a user signed up with OpenId Connect or not.", + max_length=250, + verbose_name="OpenId Connect id", + ), + ), + ] diff --git a/src/open_inwoner/accounts/models.py b/src/open_inwoner/accounts/models.py index 3b5dc8a55d..dee221df3b 100644 --- a/src/open_inwoner/accounts/models.py +++ b/src/open_inwoner/accounts/models.py @@ -96,6 +96,13 @@ class User(AbstractBaseUser, PermissionsMixin): related_name="selected_by", blank=True, ) + oidc_id = models.CharField( + verbose_name=_("OpenId Connect id"), + max_length=250, + default="", + blank=True, + help_text="This field indicates if a user signed up with OpenId Connect or not.", + ) objects = UserManager() digid_objects = DigidManager() @@ -173,11 +180,11 @@ def get_interests(self) -> str: def require_necessary_fields(self) -> bool: """returns whether user needs to fill in necessary fields""" - return ( - self.login_type == LoginTypeChoices.digid - and not self.first_name - and not self.last_name - ) + if self.login_type == LoginTypeChoices.digid: + return not self.first_name or not self.last_name + elif self.login_type == LoginTypeChoices.oidc: + return not self.email or self.email.endswith("@example.org") + return False def get_logout_url(self) -> str: return ( diff --git a/src/open_inwoner/accounts/signals.py b/src/open_inwoner/accounts/signals.py index ddc6fafb19..326802f3a6 100644 --- a/src/open_inwoner/accounts/signals.py +++ b/src/open_inwoner/accounts/signals.py @@ -9,6 +9,7 @@ "admin": _("user was logged in via admin page"), "frontend_email": _("user was logged in via frontend using email"), "frontend_digid": _("user was logged in via frontend using digid"), + "frontend_oidc": _("user was logged in via frontend using OpenIdConnect"), "logout": _("user was logged out"), } @@ -21,6 +22,8 @@ def log_user_login(sender, user, request, *args, **kwargs): user_action(request, user, MESSAGE_TYPE["admin"]) elif current_path == reverse("digid:acs"): user_action(request, user, MESSAGE_TYPE["frontend_digid"]) + elif current_path == reverse("oidc_authentication_callback"): + user_action(request, user, MESSAGE_TYPE["frontend_oidc"]) else: user_action(request, user, MESSAGE_TYPE["frontend_email"]) diff --git a/src/open_inwoner/accounts/templates/registration/login.html b/src/open_inwoner/accounts/templates/registration/login.html index 95022a9d5a..3e3d97173a 100644 --- a/src/open_inwoner/accounts/templates/registration/login.html +++ b/src/open_inwoner/accounts/templates/registration/login.html @@ -1,5 +1,5 @@ {% extends 'master.html' %} -{% load i18n static logo_tags grid_tags card_tags form_tags link_tags button_tags %} +{% load i18n static logo_tags grid_tags card_tags form_tags link_tags button_tags solo_tags %} {% block header_image %} @@ -19,9 +19,24 @@