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

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

View File

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

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

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