diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e9643c5..fec9586 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -60,13 +60,14 @@ repos: rev: v1.12.1 hooks: - id: blacken-docs -- repo: https://github.com/hcodes/yaspeller.git - rev: v8.0.1 - hooks: - - id: yaspeller - - types: - - markdown +# YASpeller does not seem to work anymore +# - repo: https://github.com/hcodes/yaspeller.git +# rev: v8.0.1 +# hooks: +# - id: yaspeller +# +# types: +# - markdown - repo: https://github.com/kadrach/pre-commit-gitlabci-lint rev: 22d0495c9894e8b27cc37c2ed5d9a6b46385a44c hooks: diff --git a/INSTALL.md b/INSTALL.md index 4d41c62..37bda58 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -19,19 +19,19 @@ sudo apt-get install git python3-virtualenv libmariadb-dev libldap2-dev libsasl2 ## Checkout -~~~~bash +```bash git clone https://git.linux-kernel.at/oliver/ivatar.git cd ivatar -~~~~ +``` ## Virtual environment -~~~~bash -virtualenv -p python3 .virtualenv +```bash +virtualenv -p python3 .virtualenv source .virtualenv/bin/activate pip install pillow pip install -r requirements.txt -~~~~ +``` ## (SQL) Migrations @@ -58,10 +58,27 @@ pip install -r requirements.txt ``` ## Running the testsuite + ``` ./manage.py test -v3 # Or any other verbosity level you like ``` +## OpenID Connect authentication with Fedora + +To enable OpenID Connect (OIDC) authentication with Fedora, you must have obtained a `client_id` and `client_secret` pair from the Fedora Infrastructure. +Then you must set these values in `config_local.py`: + +``` +SOCIAL_AUTH_FEDORA_KEY = "the-client-id" +SOCIAL_AUTH_FEDORA_SECRET = "the-client-secret" +``` + +You can override the location of the OIDC provider with the `SOCIAL_AUTH_FEDORA_OIDC_ENDPOINT` setting. For example, to authenticate with Fedora's staging environment, set this in `config_local.py`: + +``` +SOCIAL_AUTH_FEDORA_OIDC_ENDPOINT = "https://id.stg.fedoraproject.org" +``` + # Production deployment Webserver (non-cloudy) To deploy this Django application with WSGI on Apache, NGINX or any other web server, please refer to the the webserver documentation; There are also plenty of howtos on the net (I'll not LMGTFY...) @@ -82,4 +99,4 @@ There is a file called ebcreate.txt as well as a directory called .ebextensions, ## Database -It should work with SQLite (do *not* use in production!), MySQL/MariaDB, as well as PostgreSQL. +It should work with SQLite (do _not_ use in production!), MySQL/MariaDB, as well as PostgreSQL. diff --git a/config.py b/config.py index 53f0689..f5de080 100644 --- a/config.py +++ b/config.py @@ -44,6 +44,7 @@ AUTHENTICATION_BACKENDS = ( # See INSTALL for more information. # 'django_auth_ldap.backend.LDAPBackend', "django_openid_auth.auth.OpenIDBackend", + "ivatar.ivataraccount.auth.FedoraOpenIdConnect", "django.contrib.auth.backends.ModelBackend", ) @@ -58,6 +59,10 @@ TEMPLATES[0]["OPTIONS"]["context_processors"].append( OPENID_CREATE_USERS = True OPENID_UPDATE_DETAILS_FROM_SREG = True +SOCIAL_AUTH_JSONFIELD_ENABLED = True +# Fedora authentication (OIDC). You need to set these two values to use it. +SOCIAL_AUTH_FEDORA_KEY = None # Also known as client_id +SOCIAL_AUTH_FEDORA_SECRET = None # Also known as client_secret SITE_NAME = os.environ.get("SITE_NAME", "libravatar") IVATAR_VERSION = "1.8.0" diff --git a/ivatar/ivataraccount/auth.py b/ivatar/ivataraccount/auth.py new file mode 100644 index 0000000..a54e579 --- /dev/null +++ b/ivatar/ivataraccount/auth.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +from social_core.backends.open_id_connect import OpenIdConnectAuth + +from ivatar.ivataraccount.models import ConfirmedEmail, Photo +from ivatar.settings import logger, TRUST_EMAIL_FROM_SOCIAL_AUTH_BACKENDS + + +class FedoraOpenIdConnect(OpenIdConnectAuth): + name = "fedora" + USERNAME_KEY = "nickname" + OIDC_ENDPOINT = "https://id.fedoraproject.org" + DEFAULT_SCOPE = ["openid", "profile", "email"] + TOKEN_ENDPOINT_AUTH_METHOD = "client_secret_post" + + +# Pipeline methods + + +def add_confirmed_email(backend, user, response, *args, **kwargs): + """Add a ConfirmedEmail if we trust the auth backend to validate email.""" + if not kwargs.get("is_new", False): + return None # Only act on account creation + if backend.name not in TRUST_EMAIL_FROM_SOCIAL_AUTH_BACKENDS: + return None + if ConfirmedEmail.objects.filter(email=user.email).count() > 0: + # email already exists + return None + (confirmed_id, external_photos) = ConfirmedEmail.objects.create_confirmed_email( + user, user.email, True + ) + confirmed_email = ConfirmedEmail.objects.get(id=confirmed_id) + logger.debug( + "Email %s added upon creation of user %s", confirmed_email.email, user.pk + ) + photo = Photo.objects.create(user=user, ip_address=confirmed_email.ip_address) + import_result = photo.import_image("Gravatar", confirmed_email.email) + if import_result: + logger.debug("Gravatar image imported for %s", confirmed_email.email) + + +def associate_by_confirmed_email(backend, details, user=None, *args, **kwargs): + """ + Associate current auth with a user that has their email address as ConfirmedEmail in the DB. + """ + if user: + return None + email = details.get("email") + if not email: + return None + try: + confirmed_email = ConfirmedEmail.objects.get(email=email) + except ConfirmedEmail.DoesNotExist: + return None + user = confirmed_email.user + logger.debug("Found a matching ConfirmedEmail for %s upon login", user.username) + return {"user": user, "is_new": False} diff --git a/ivatar/ivataraccount/templates/login.html b/ivatar/ivataraccount/templates/login.html index a1ca534..e71d843 100644 --- a/ivatar/ivataraccount/templates/login.html +++ b/ivatar/ivataraccount/templates/login.html @@ -32,6 +32,10 @@   {% trans 'Login with OpenID' %} + {% if with_fedora %} +   + {% trans 'Login with Fedora' %} + {% endif %}   {% trans 'Create new user' %}   diff --git a/ivatar/ivataraccount/test_auth.py b/ivatar/ivataraccount/test_auth.py new file mode 100644 index 0000000..fc78e9f --- /dev/null +++ b/ivatar/ivataraccount/test_auth.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +from unittest import mock + +from django.test import TestCase +from django.contrib.auth.models import User +from ivatar.ivataraccount.auth import FedoraOpenIdConnect +from ivatar.ivataraccount.models import ConfirmedEmail +from django.test import override_settings + + +@override_settings(SOCIAL_AUTH_FEDORA_OIDC_ENDPOINT="https://id.example.com/") +class AuthFedoraTestCase(TestCase): + def _authenticate(self, response): + backend = FedoraOpenIdConnect() + pipeline = backend.strategy.get_pipeline(backend) + return backend.pipeline(pipeline, response=response) + + def test_new_user(self): + """Check that a Fedora user gets a ConfirmedEmail automatically.""" + user = self._authenticate({"nickname": "testuser", "email": "test@example.com"}) + self.assertEqual(user.confirmedemail_set.count(), 1) + self.assertEqual(user.confirmedemail_set.first().email, "test@example.com") + + @mock.patch("ivatar.ivataraccount.auth.TRUST_EMAIL_FROM_SOCIAL_AUTH_BACKENDS", []) + def test_new_user_untrusted_backend(self): + """Check that ConfirmedEmails aren't automatically created for untrusted backends.""" + user = self._authenticate({"nickname": "testuser", "email": "test@example.com"}) + self.assertEqual(user.confirmedemail_set.count(), 0) + + def test_existing_user(self): + """Checks that existing users are found.""" + user = User.objects.create_user( + username="testuser", + password="password", + email="test@example.com", + first_name="test", + last_name="user", + ) + auth_user = self._authenticate( + {"nickname": "testuser", "email": "test@example.com"} + ) + self.assertEqual(auth_user, user) + # Only add ConfirmedEmails on account creation. + self.assertEqual(auth_user.confirmedemail_set.count(), 0) + + def test_existing_user_with_confirmed_email(self): + """Check that the authenticating user is found using their ConfirmedEmail.""" + user = User.objects.create_user( + username="testuser1", + password="password", + email="first@example.com", + first_name="test", + last_name="user", + ) + ConfirmedEmail.objects.create_confirmed_email(user, "second@example.com", False) + auth_user = self._authenticate( + {"nickname": "testuser2", "email": "second@example.com"} + ) + self.assertEqual(auth_user, user) + + def test_existing_confirmed_email(self): + """Check that ConfirmedEmail isn't created twice.""" + user = User.objects.create_user( + username="testuser", + password="password", + email="testuser@example.com", + first_name="test", + last_name="user", + ) + ConfirmedEmail.objects.create_confirmed_email(user, user.email, False) + auth_user = self._authenticate({"nickname": user.username, "email": user.email}) + self.assertEqual(auth_user, user) + self.assertEqual(auth_user.confirmedemail_set.count(), 1) diff --git a/ivatar/ivataraccount/test_views.py b/ivatar/ivataraccount/test_views.py index ed411da..45a4eb5 100644 --- a/ivatar/ivataraccount/test_views.py +++ b/ivatar/ivataraccount/test_views.py @@ -8,6 +8,7 @@ import contextlib # pylint: disable=too-many-lines from urllib.parse import urlsplit from io import BytesIO +from contextlib import suppress import io import os import gzip @@ -16,8 +17,10 @@ import base64 import django from django.test import TestCase from django.test import Client +from django.test import override_settings from django.urls import reverse from django.core import mail +from django.core.cache import caches from django.contrib.auth.models import User from django.contrib.auth import authenticate import hashlib @@ -40,6 +43,7 @@ from ivatar.utils import random_string TEST_IMAGE_FILE = os.path.join(settings.STATIC_ROOT, "img", "deadbeef.png") +@override_settings() class Tester(TestCase): # pylint: disable=too-many-public-methods """ Main test class @@ -49,7 +53,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods user = None username = random_string() password = random_string() - email = "%s@%s.%s" % (username, random_string(), random_string(2)) + email = "%s@%s.org" % (username, random_string()) # Dunno why random tld doesn't work, but I'm too lazy now to investigate openid = "http://%s.%s.%s/" % (username, random_string(), "org") first_name = random_string() @@ -72,6 +76,14 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods first_name=self.first_name, last_name=self.last_name, ) + # Disable caching + settings.CACHES["default"] = { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + } + caches._settings = None + with suppress(AttributeError): + # clear the existing cache connection + delattr(caches._connections, "default") def test_new_user(self): """ @@ -454,7 +466,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods ) for i in range(max_num_unconfirmed + 1): - response = self.client.post( + response = self.client.post( # noqa: F841 reverse("add_email"), { "email": "%i.%s" % (i, self.email), @@ -475,7 +487,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods settings.EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend" for _ in range(2): - response = self.client.post( + response = self.client.post( # noqa: F841 reverse("add_email"), { "email": self.email, @@ -494,7 +506,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods # Should set EMAIL_BACKEND, so no need to do it here self.test_confirm_email() - response = self.client.post( + response = self.client.post( # noqa: F841 reverse("add_email"), { "email": self.email, @@ -521,7 +533,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods confirmedemail.user = otheruser confirmedemail.save() - response = self.client.post( + response = self.client.post( # noqa: F841 reverse("add_email"), { "email": self.email, diff --git a/ivatar/ivataraccount/views.py b/ivatar/ivataraccount/views.py index 45e8705..1e53709 100644 --- a/ivatar/ivataraccount/views.py +++ b/ivatar/ivataraccount/views.py @@ -48,6 +48,7 @@ from ivatar.settings import ( MAX_PHOTO_SIZE, JPEG_QUALITY, AVATAR_MAX_SIZE, + SOCIAL_AUTH_FEDORA_KEY, ) from .gravatar import get_photo as get_gravatar_photo @@ -1099,6 +1100,11 @@ class IvatarLoginView(LoginView): return HttpResponseRedirect(reverse_lazy("profile")) return super().get(self, request, args, kwargs) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["with_fedora"] = SOCIAL_AUTH_FEDORA_KEY is not None + return context + @method_decorator(login_required, name="dispatch") class ProfileView(TemplateView): diff --git a/ivatar/settings.py b/ivatar/settings.py index f5ca083..bf4b3ee 100644 --- a/ivatar/settings.py +++ b/ivatar/settings.py @@ -32,6 +32,7 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "social_django", ] MIDDLEWARE = [ @@ -59,6 +60,7 @@ TEMPLATES = [ "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "django.template.context_processors.i18n", + "social_django.context_processors.login_redirect", ], "debug": DEBUG, }, @@ -122,6 +124,50 @@ if not DEBUG: SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_HSTS_PRELOAD = True + +# Social authentication +TRUST_EMAIL_FROM_SOCIAL_AUTH_BACKENDS = ["fedora"] +SOCIAL_AUTH_PIPELINE = ( + # Get the information we can about the user and return it in a simple + # format to create the user instance later. In some cases the details are + # already part of the auth response from the provider, but sometimes this + # could hit a provider API. + "social_core.pipeline.social_auth.social_details", + # Get the social uid from whichever service we're authing thru. The uid is + # the unique identifier of the given user in the provider. + "social_core.pipeline.social_auth.social_uid", + # Verifies that the current auth process is valid within the current + # project, this is where emails and domains whitelists are applied (if + # defined). + "social_core.pipeline.social_auth.auth_allowed", + # Checks if the current social-account is already associated in the site. + "social_core.pipeline.social_auth.social_user", + # Make up a username for this person, appends a random string at the end if + # there's any collision. + "social_core.pipeline.user.get_username", + # Send a validation email to the user to verify its email address. + # Disabled by default. + # 'social_core.pipeline.mail.mail_validation', + # Associates the current social details with another user account with + # a similar email address. Disabled by default. + "social_core.pipeline.social_auth.associate_by_email", + # Associates the current social details with an existing user account with + # a matching ConfirmedEmail. + "ivatar.ivataraccount.auth.associate_by_confirmed_email", + # Create a user account if we haven't found one yet. + "social_core.pipeline.user.create_user", + # Create the record that associates the social account with the user. + "social_core.pipeline.social_auth.associate_user", + # Populate the extra_data field in the social record with the values + # specified by settings (and the default ones like access_token, etc). + "social_core.pipeline.social_auth.load_extra_data", + # Update the user record with any changed info from the auth service. + "social_core.pipeline.user.user_details", + # Create the ConfirmedEmail if appropriate. + "ivatar.ivataraccount.auth.add_confirmed_email", +) + + # Internationalization # https://docs.djangoproject.com/en/2.0/topics/i18n/ diff --git a/ivatar/urls.py b/ivatar/urls.py index 2504c49..73c7fb3 100644 --- a/ivatar/urls.py +++ b/ivatar/urls.py @@ -16,6 +16,7 @@ urlpatterns = [ # pylint: disable=invalid-name path("admin/", admin.site.urls), path("i18n/", include("django.conf.urls.i18n")), path("openid/", include("django_openid_auth.urls")), + path("auth/", include("social_django.urls", namespace="social")), path("tools/", include("ivatar.tools.urls")), re_path( r"avatar/(?P\w{64})", AvatarImageView.as_view(), name="avatar_view"