Merge branch 'oidc' into 'devel'

Add support for OIDC authentication with Fedora

See merge request oliver/ivatar!242
This commit is contained in:
Oliver Falk
2025-04-15 11:10:30 +00:00
10 changed files with 239 additions and 18 deletions

View File

@@ -60,13 +60,14 @@ repos:
rev: v1.12.1 rev: v1.12.1
hooks: hooks:
- id: blacken-docs - id: blacken-docs
- repo: https://github.com/hcodes/yaspeller.git # YASpeller does not seem to work anymore
rev: v8.0.1 # - repo: https://github.com/hcodes/yaspeller.git
hooks: # rev: v8.0.1
- id: yaspeller # hooks:
# - id: yaspeller
types: #
- markdown # types:
# - markdown
- repo: https://github.com/kadrach/pre-commit-gitlabci-lint - repo: https://github.com/kadrach/pre-commit-gitlabci-lint
rev: 22d0495c9894e8b27cc37c2ed5d9a6b46385a44c rev: 22d0495c9894e8b27cc37c2ed5d9a6b46385a44c
hooks: hooks:

View File

@@ -19,19 +19,19 @@ sudo apt-get install git python3-virtualenv libmariadb-dev libldap2-dev libsasl2
## Checkout ## Checkout
~~~~bash ```bash
git clone https://git.linux-kernel.at/oliver/ivatar.git git clone https://git.linux-kernel.at/oliver/ivatar.git
cd ivatar cd ivatar
~~~~ ```
## Virtual environment ## Virtual environment
~~~~bash ```bash
virtualenv -p python3 .virtualenv virtualenv -p python3 .virtualenv
source .virtualenv/bin/activate source .virtualenv/bin/activate
pip install pillow pip install pillow
pip install -r requirements.txt pip install -r requirements.txt
~~~~ ```
## (SQL) Migrations ## (SQL) Migrations
@@ -58,10 +58,27 @@ pip install -r requirements.txt
``` ```
## Running the testsuite ## Running the testsuite
``` ```
./manage.py test -v3 # Or any other verbosity level you like ./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) # 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...) 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 ## 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.

View File

@@ -44,6 +44,7 @@ AUTHENTICATION_BACKENDS = (
# See INSTALL for more information. # See INSTALL for more information.
# 'django_auth_ldap.backend.LDAPBackend', # 'django_auth_ldap.backend.LDAPBackend',
"django_openid_auth.auth.OpenIDBackend", "django_openid_auth.auth.OpenIDBackend",
"ivatar.ivataraccount.auth.FedoraOpenIdConnect",
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
) )
@@ -58,6 +59,10 @@ TEMPLATES[0]["OPTIONS"]["context_processors"].append(
OPENID_CREATE_USERS = True OPENID_CREATE_USERS = True
OPENID_UPDATE_DETAILS_FROM_SREG = 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") SITE_NAME = os.environ.get("SITE_NAME", "libravatar")
IVATAR_VERSION = "1.8.0" IVATAR_VERSION = "1.8.0"

View File

@@ -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}

View File

@@ -32,6 +32,10 @@
<button type="submit" class="button">{% trans 'Login' %}</button> <button type="submit" class="button">{% trans 'Login' %}</button>
&nbsp; &nbsp;
<a href="{% url 'openid-login' %}" class="button">{% trans 'Login with OpenID' %}</a> <a href="{% url 'openid-login' %}" class="button">{% trans 'Login with OpenID' %}</a>
{% if with_fedora %}
&nbsp;
<a href="{% url "social:begin" "fedora" %}" class="button">{% trans 'Login with Fedora' %}</a>
{% endif %}
&nbsp; &nbsp;
<a href="{% url 'new_account' %}" class="button">{% trans 'Create new user' %}</a> <a href="{% url 'new_account' %}" class="button">{% trans 'Create new user' %}</a>
&nbsp; &nbsp;

View File

@@ -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)

View File

@@ -8,6 +8,7 @@ import contextlib
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
from urllib.parse import urlsplit from urllib.parse import urlsplit
from io import BytesIO from io import BytesIO
from contextlib import suppress
import io import io
import os import os
import gzip import gzip
@@ -16,8 +17,10 @@ import base64
import django import django
from django.test import TestCase from django.test import TestCase
from django.test import Client from django.test import Client
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from django.core import mail from django.core import mail
from django.core.cache import caches
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
import hashlib import hashlib
@@ -40,6 +43,7 @@ from ivatar.utils import random_string
TEST_IMAGE_FILE = os.path.join(settings.STATIC_ROOT, "img", "deadbeef.png") TEST_IMAGE_FILE = os.path.join(settings.STATIC_ROOT, "img", "deadbeef.png")
@override_settings()
class Tester(TestCase): # pylint: disable=too-many-public-methods class Tester(TestCase): # pylint: disable=too-many-public-methods
""" """
Main test class Main test class
@@ -49,7 +53,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
user = None user = None
username = random_string() username = random_string()
password = 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 # Dunno why random tld doesn't work, but I'm too lazy now to investigate
openid = "http://%s.%s.%s/" % (username, random_string(), "org") openid = "http://%s.%s.%s/" % (username, random_string(), "org")
first_name = random_string() first_name = random_string()
@@ -72,6 +76,14 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
first_name=self.first_name, first_name=self.first_name,
last_name=self.last_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): 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): for i in range(max_num_unconfirmed + 1):
response = self.client.post( response = self.client.post( # noqa: F841
reverse("add_email"), reverse("add_email"),
{ {
"email": "%i.%s" % (i, self.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" settings.EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend"
for _ in range(2): for _ in range(2):
response = self.client.post( response = self.client.post( # noqa: F841
reverse("add_email"), reverse("add_email"),
{ {
"email": self.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 # Should set EMAIL_BACKEND, so no need to do it here
self.test_confirm_email() self.test_confirm_email()
response = self.client.post( response = self.client.post( # noqa: F841
reverse("add_email"), reverse("add_email"),
{ {
"email": self.email, "email": self.email,
@@ -521,7 +533,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
confirmedemail.user = otheruser confirmedemail.user = otheruser
confirmedemail.save() confirmedemail.save()
response = self.client.post( response = self.client.post( # noqa: F841
reverse("add_email"), reverse("add_email"),
{ {
"email": self.email, "email": self.email,

View File

@@ -48,6 +48,7 @@ from ivatar.settings import (
MAX_PHOTO_SIZE, MAX_PHOTO_SIZE,
JPEG_QUALITY, JPEG_QUALITY,
AVATAR_MAX_SIZE, AVATAR_MAX_SIZE,
SOCIAL_AUTH_FEDORA_KEY,
) )
from .gravatar import get_photo as get_gravatar_photo from .gravatar import get_photo as get_gravatar_photo
@@ -1099,6 +1100,11 @@ class IvatarLoginView(LoginView):
return HttpResponseRedirect(reverse_lazy("profile")) return HttpResponseRedirect(reverse_lazy("profile"))
return super().get(self, request, args, kwargs) 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") @method_decorator(login_required, name="dispatch")
class ProfileView(TemplateView): class ProfileView(TemplateView):

View File

@@ -32,6 +32,7 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"social_django",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -59,6 +60,7 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"django.template.context_processors.i18n", "django.template.context_processors.i18n",
"social_django.context_processors.login_redirect",
], ],
"debug": DEBUG, "debug": DEBUG,
}, },
@@ -122,6 +124,50 @@ if not DEBUG:
SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = 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 # Internationalization
# https://docs.djangoproject.com/en/2.0/topics/i18n/ # https://docs.djangoproject.com/en/2.0/topics/i18n/

View File

@@ -16,6 +16,7 @@ urlpatterns = [ # pylint: disable=invalid-name
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("i18n/", include("django.conf.urls.i18n")), path("i18n/", include("django.conf.urls.i18n")),
path("openid/", include("django_openid_auth.urls")), path("openid/", include("django_openid_auth.urls")),
path("auth/", include("social_django.urls", namespace="social")),
path("tools/", include("ivatar.tools.urls")), path("tools/", include("ivatar.tools.urls")),
re_path( re_path(
r"avatar/(?P<digest>\w{64})", AvatarImageView.as_view(), name="avatar_view" r"avatar/(?P<digest>\w{64})", AvatarImageView.as_view(), name="avatar_view"