Add support for OIDC authentication with Fedora

This adds support for authenticating with Fedora's OpenID Connect (OIDC) provider.

Existing users will be matched by email address, they should be able to use the new authentication method transparently.

This requires getting a `client_id` and a `client_secret` from Fedora Infra, see `INSTALL.md`.

Signed-off-by: Aurélien Bompard <aurelien@bompard.org>
This commit is contained in:
Aurélien Bompard
2025-04-04 18:19:39 +02:00
parent 56780cba69
commit 99b4fdcbcd
9 changed files with 222 additions and 13 deletions

View File

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

View File

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

View File

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

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>
&nbsp;
<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;
<a href="{% url 'new_account' %}" class="button">{% trans 'Create new user' %}</a>
&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

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

View File

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

View File

@@ -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<digest>\w{64})", AvatarImageView.as_view(), name="avatar_view"