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"