mirror of
https://git.linux-kernel.at/oliver/ivatar.git
synced 2025-11-11 18:56:23 +00:00
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:
@@ -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:
|
||||
|
||||
29
INSTALL.md
29
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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
56
ivatar/ivataraccount/auth.py
Normal file
56
ivatar/ivataraccount/auth.py
Normal 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}
|
||||
@@ -32,6 +32,10 @@
|
||||
<button type="submit" class="button">{% trans 'Login' %}</button>
|
||||
|
||||
<a href="{% url 'openid-login' %}" class="button">{% trans 'Login with OpenID' %}</a>
|
||||
{% if with_fedora %}
|
||||
|
||||
<a href="{% url "social:begin" "fedora" %}" class="button">{% trans 'Login with Fedora' %}</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="{% url 'new_account' %}" class="button">{% trans 'Create new user' %}</a>
|
||||
|
||||
|
||||
73
ivatar/ivataraccount/test_auth.py
Normal file
73
ivatar/ivataraccount/test_auth.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user