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 cb4e18c..951a25b 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.7.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/views.py b/ivatar/ivataraccount/views.py index f993cb5..9a1e45a 100644 --- a/ivatar/ivataraccount/views.py +++ b/ivatar/ivataraccount/views.py @@ -46,6 +46,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 @@ -1012,6 +1013,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 3056ab4..6aa803b 100644 --- a/ivatar/urls.py +++ b/ivatar/urls.py @@ -13,6 +13,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"