mirror of
https://git.linux-kernel.at/oliver/ivatar.git
synced 2025-11-11 10:46:24 +00:00
Merge branch 'oidc' into 'devel'
Add support for OIDC authentication with Fedora See merge request oliver/ivatar!242
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.8.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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user