From 4ce46ff2a000646a3dbab80f0ca9fd8d7f8ae24c Mon Sep 17 00:00:00 2001
From: "Joshua M. Boniface" <joshua@boniface.me>
Date: Wed, 22 Aug 2018 18:10:39 +0000
Subject: [PATCH] Implement LDAP authentication
---
.env.dev | 1 +
.gitlab-ci.yml | 2 +
api/config/settings/common.py | 65 +++++++++++++++++++++
api/config/settings/local.py | 1 +
api/funkwhale_api/federation/utils.py | 20 +++++++
api/funkwhale_api/federation/views.py | 4 +-
api/funkwhale_api/users/models.py | 19 ++++--
api/requirements.apt | 2 +
api/requirements.pac | 2 +
api/requirements/base.txt | 4 ++
api/tests/federation/test_views.py | 5 +-
api/tests/users/test_ldap.py | 22 +++++++
api/tests/users/test_models.py | 24 ++++++--
changes/changelog.d/194.feature | 14 +++++
deploy/env.prod.sample | 15 +++++
docs/installation/external_dependencies.rst | 5 ++
docs/installation/ldap.rst | 42 +++++++++++++
17 files changed, 232 insertions(+), 15 deletions(-)
create mode 100644 api/tests/users/test_ldap.py
create mode 100644 changes/changelog.d/194.feature
create mode 100644 docs/installation/ldap.rst
diff --git a/.env.dev b/.env.dev
index d4833ab0..f13026e2 100644
--- a/.env.dev
+++ b/.env.dev
@@ -11,3 +11,4 @@ VUE_PORT=8080
MUSIC_DIRECTORY_PATH=/music
BROWSABLE_API_ENABLED=True
FORWARDED_PROTO=http
+LDAP_ENABLED=False
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 57b7dfc7..c7a43c94 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -148,6 +148,8 @@ test_api:
- branches
before_script:
- cd api
+ - apt-get update
+ - grep "^[^#;]" requirements.apt | grep -Fv "python3-dev" | xargs apt-get install -y --no-install-recommends
- pip install -r requirements/base.txt
- pip install -r requirements/local.txt
- pip install -r requirements/test.txt
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 11415797..6a4430d8 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -310,6 +310,71 @@ AUTH_USER_MODEL = "users.User"
LOGIN_REDIRECT_URL = "users:redirect"
LOGIN_URL = "account_login"
+# LDAP AUTHENTICATION CONFIGURATION
+# ------------------------------------------------------------------------------
+AUTH_LDAP_ENABLED = env.bool("LDAP_ENABLED", default=False)
+if AUTH_LDAP_ENABLED:
+
+ # Import the LDAP modules here; this way, we don't need the dependency unless someone
+ # actually enables the LDAP support
+ import ldap
+ from django_auth_ldap.config import LDAPSearch, LDAPSearchUnion, GroupOfNamesType
+
+ # Add LDAP to the authentication backends
+ AUTHENTICATION_BACKENDS += ("django_auth_ldap.backend.LDAPBackend",)
+
+ # Basic configuration
+ AUTH_LDAP_SERVER_URI = env("LDAP_SERVER_URI")
+ AUTH_LDAP_BIND_DN = env("LDAP_BIND_DN", default="")
+ AUTH_LDAP_BIND_PASSWORD = env("LDAP_BIND_PASSWORD", default="")
+ AUTH_LDAP_SEARCH_FILTER = env("LDAP_SEARCH_FILTER", default="(uid={0})").format(
+ "%(user)s"
+ )
+ AUTH_LDAP_START_TLS = env.bool("LDAP_START_TLS", default=False)
+
+ DEFAULT_USER_ATTR_MAP = [
+ "first_name:givenName",
+ "last_name:sn",
+ "username:cn",
+ "email:mail",
+ ]
+ LDAP_USER_ATTR_MAP = env.list("LDAP_USER_ATTR_MAP", default=DEFAULT_USER_ATTR_MAP)
+ AUTH_LDAP_USER_ATTR_MAP = {}
+ for m in LDAP_USER_ATTR_MAP:
+ funkwhale_field, ldap_field = m.split(":")
+ AUTH_LDAP_USER_ATTR_MAP[funkwhale_field.strip()] = ldap_field.strip()
+
+ # Determine root DN supporting multiple root DNs
+ AUTH_LDAP_ROOT_DN = env("LDAP_ROOT_DN")
+ AUTH_LDAP_ROOT_DN_LIST = []
+ for ROOT_DN in AUTH_LDAP_ROOT_DN.split():
+ AUTH_LDAP_ROOT_DN_LIST.append(
+ LDAPSearch(ROOT_DN, ldap.SCOPE_SUBTREE, AUTH_LDAP_SEARCH_FILTER)
+ )
+ # Search for the user in all the root DNs
+ AUTH_LDAP_USER_SEARCH = LDAPSearchUnion(*AUTH_LDAP_ROOT_DN_LIST)
+
+ # Search for group types
+ LDAP_GROUP_DN = env("LDAP_GROUP_DN", default="")
+ if LDAP_GROUP_DN:
+ AUTH_LDAP_GROUP_DN = LDAP_GROUP_DN
+ # Get filter
+ AUTH_LDAP_GROUP_FILTER = env("LDAP_GROUP_FILER", default="")
+ # Search for the group in the specified DN
+ AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
+ AUTH_LDAP_GROUP_DN, ldap.SCOPE_SUBTREE, AUTH_LDAP_GROUP_FILTER
+ )
+ AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()
+
+ # Configure basic group support
+ LDAP_REQUIRE_GROUP = env("LDAP_REQUIRE_GROUP", default="")
+ if LDAP_REQUIRE_GROUP:
+ AUTH_LDAP_REQUIRE_GROUP = LDAP_REQUIRE_GROUP
+ LDAP_DENY_GROUP = env("LDAP_DENY_GROUP", default="")
+ if LDAP_DENY_GROUP:
+ AUTH_LDAP_DENY_GROUP = LDAP_DENY_GROUP
+
+
# SLUGLIFIER
AUTOSLUG_SLUGIFY_FUNCTION = "slugify.slugify"
diff --git a/api/config/settings/local.py b/api/config/settings/local.py
index b8df4bdb..f639fabd 100644
--- a/api/config/settings/local.py
+++ b/api/config/settings/local.py
@@ -67,6 +67,7 @@ LOGGING = {
"propagate": True,
"level": "DEBUG",
},
+ "django_auth_ldap": {"handlers": ["console"], "level": "DEBUG"},
"": {"level": "DEBUG", "handlers": ["console"]},
},
}
diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py
index 71f22746..c87bde6a 100644
--- a/api/funkwhale_api/federation/utils.py
+++ b/api/funkwhale_api/federation/utils.py
@@ -1,3 +1,5 @@
+import unicodedata
+import re
from django.conf import settings
@@ -32,3 +34,21 @@ def clean_wsgi_headers(raw_headers):
cleaned[cleaned_header] = value
return cleaned
+
+
+def slugify_username(username):
+ """
+ Given a username such as "hello M. world", returns a username
+ suitable for federation purpose (hello_M_world).
+
+ Preserves the original case.
+
+ Code is borrowed from django's slugify function.
+ """
+
+ value = str(username)
+ value = (
+ unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
+ )
+ value = re.sub(r"[^\w\s-]", "", value).strip()
+ return re.sub(r"[-\s]+", "_", value)
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
index 29f56630..ae20ab1d 100644
--- a/api/funkwhale_api/federation/views.py
+++ b/api/funkwhale_api/federation/views.py
@@ -33,7 +33,7 @@ class FederationMixin(object):
class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
- lookup_field = "user__username"
+ lookup_field = "preferred_username"
lookup_value_regex = ".*"
authentication_classes = [authentication.SignatureAuthentication]
permission_classes = []
@@ -136,7 +136,7 @@ class WellKnownViewSet(viewsets.GenericViewSet):
actor = actors.SYSTEM_ACTORS[username].get_actor_instance()
else:
try:
- actor = models.Actor.objects.local().get(user__username=username)
+ actor = models.Actor.objects.local().get(preferred_username=username)
except models.Actor.DoesNotExist:
raise forms.ValidationError("Invalid username")
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index 26ffb5a9..3ad56ea6 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -17,6 +17,7 @@ from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
+from django_auth_ldap.backend import populate_user as ldap_populate_user
from versatileimagefield.fields import VersatileImageField
from versatileimagefield.image_warmer import VersatileImageFieldWarmer
@@ -220,25 +221,25 @@ class Invitation(models.Model):
def create_actor(user):
- username = user.username
+ username = federation_utils.slugify_username(user.username)
private, public = keys.get_key_pair()
args = {
"preferred_username": username,
"domain": settings.FEDERATION_HOSTNAME,
"type": "Person",
- "name": username,
+ "name": user.username,
"manually_approves_followers": False,
"url": federation_utils.full_url(
- reverse("federation:actors-detail", kwargs={"user__username": username})
+ reverse("federation:actors-detail", kwargs={"preferred_username": username})
),
"shared_inbox_url": federation_utils.full_url(
- reverse("federation:actors-inbox", kwargs={"user__username": username})
+ reverse("federation:actors-inbox", kwargs={"preferred_username": username})
),
"inbox_url": federation_utils.full_url(
- reverse("federation:actors-inbox", kwargs={"user__username": username})
+ reverse("federation:actors-inbox", kwargs={"preferred_username": username})
),
"outbox_url": federation_utils.full_url(
- reverse("federation:actors-outbox", kwargs={"user__username": username})
+ reverse("federation:actors-outbox", kwargs={"preferred_username": username})
),
}
args["private_key"] = private.decode("utf-8")
@@ -247,6 +248,12 @@ def create_actor(user):
return federation_models.Actor.objects.create(**args)
+@receiver(ldap_populate_user)
+def init_ldap_user(sender, user, ldap_user, **kwargs):
+ if not user.actor:
+ user.actor = create_actor(user)
+
+
@receiver(models.signals.post_save, sender=User)
def warm_user_avatar(sender, instance, **kwargs):
if not instance.avatar:
diff --git a/api/requirements.apt b/api/requirements.apt
index 224ff955..6e4db7a3 100644
--- a/api/requirements.apt
+++ b/api/requirements.apt
@@ -6,3 +6,5 @@ libmagic-dev
libpq-dev
postgresql-client
python3-dev
+libldap2-dev
+libsasl2-dev
diff --git a/api/requirements.pac b/api/requirements.pac
index 7e7cb8a0..c173600a 100644
--- a/api/requirements.pac
+++ b/api/requirements.pac
@@ -4,3 +4,5 @@ ffmpeg
libjpeg-turbo
libpqxx
python
+libldap
+libsasl
diff --git a/api/requirements/base.txt b/api/requirements/base.txt
index bb441ac3..fdd6f3d8 100644
--- a/api/requirements/base.txt
+++ b/api/requirements/base.txt
@@ -65,3 +65,7 @@ cryptography>=2,<3
# clone until the branch is merged and released upstream
git+https://github.com/EliotBerriot/requests-http-signature.git@signature-header-support
django-cleanup==2.1.0
+
+# for LDAP authentication
+python-ldap==3.1.0
+django-auth-ldap==1.7.0
diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py
index 4f1f471d..a99c71ff 100644
--- a/api/tests/federation/test_views.py
+++ b/api/tests/federation/test_views.py
@@ -424,7 +424,10 @@ def test_library_track_action_import(factories, superuser_api_client, mocker):
def test_local_actor_detail(factories, api_client):
user = factories["users.User"](with_actor=True)
- url = reverse("federation:actors-detail", kwargs={"user__username": user.username})
+ url = reverse(
+ "federation:actors-detail",
+ kwargs={"preferred_username": user.actor.preferred_username},
+ )
serializer = serializers.ActorSerializer(user.actor)
response = api_client.get(url)
diff --git a/api/tests/users/test_ldap.py b/api/tests/users/test_ldap.py
new file mode 100644
index 00000000..1010d02c
--- /dev/null
+++ b/api/tests/users/test_ldap.py
@@ -0,0 +1,22 @@
+from django.contrib.auth import get_backends
+
+from django_auth_ldap import backend
+
+
+def test_ldap_user_creation_also_creates_actor(settings, factories, mocker):
+ actor = factories["federation.Actor"]()
+ mocker.patch("funkwhale_api.users.models.create_actor", return_value=actor)
+ mocker.patch(
+ "django_auth_ldap.backend.LDAPBackend.ldap_to_django_username",
+ return_value="hello",
+ )
+ settings.AUTHENTICATION_BACKENDS += ("django_auth_ldap.backend.LDAPBackend",)
+ # django-auth-ldap offers a populate_user signal we can use
+ # to create our user actor if it does not exists
+ ldap_backend = get_backends()[-1]
+ ldap_user = backend._LDAPUser(ldap_backend, username="hello")
+ ldap_user._user_attrs = {"hello": "world"}
+ ldap_user._get_or_create_user()
+ ldap_user._user.refresh_from_db()
+
+ assert ldap_user._user.actor == actor
diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py
index 39a5bd32..0d03c0fc 100644
--- a/api/tests/users/test_models.py
+++ b/api/tests/users/test_models.py
@@ -133,23 +133,35 @@ def test_can_filter_closed_invitations(factories):
def test_creating_actor_from_user(factories, settings):
- user = factories["users.User"]()
+ user = factories["users.User"](username="Hello M. world")
actor = models.create_actor(user)
- assert actor.preferred_username == user.username
+ assert actor.preferred_username == "Hello_M_world" # slugified
assert actor.domain == settings.FEDERATION_HOSTNAME
assert actor.type == "Person"
assert actor.name == user.username
assert actor.manually_approves_followers is False
assert actor.url == federation_utils.full_url(
- reverse("federation:actors-detail", kwargs={"user__username": user.username})
+ reverse(
+ "federation:actors-detail",
+ kwargs={"preferred_username": actor.preferred_username},
+ )
)
assert actor.shared_inbox_url == federation_utils.full_url(
- reverse("federation:actors-inbox", kwargs={"user__username": user.username})
+ reverse(
+ "federation:actors-inbox",
+ kwargs={"preferred_username": actor.preferred_username},
+ )
)
assert actor.inbox_url == federation_utils.full_url(
- reverse("federation:actors-inbox", kwargs={"user__username": user.username})
+ reverse(
+ "federation:actors-inbox",
+ kwargs={"preferred_username": actor.preferred_username},
+ )
)
assert actor.outbox_url == federation_utils.full_url(
- reverse("federation:actors-outbox", kwargs={"user__username": user.username})
+ reverse(
+ "federation:actors-outbox",
+ kwargs={"preferred_username": actor.preferred_username},
+ )
)
diff --git a/changes/changelog.d/194.feature b/changes/changelog.d/194.feature
new file mode 100644
index 00000000..736c8a24
--- /dev/null
+++ b/changes/changelog.d/194.feature
@@ -0,0 +1,14 @@
+Authentication using a LDAP directory (#194)
+
+Using a LDAP directory to authenticate to your Funkwhale instance
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Funkwhale now support LDAP as an authentication source: you can configure
+your instance to delegate login to a LDAP directory, which is especially
+useful when you have an existing directory and don't want to manage users
+manually.
+
+You can use this authentication backend side by side with the classic one.
+
+Have a look at https://docs.funkwhale.audio/installation/ldap.html
+for detailed instructions on how to set this up.
diff --git a/deploy/env.prod.sample b/deploy/env.prod.sample
index 838e8fef..26702cfa 100644
--- a/deploy/env.prod.sample
+++ b/deploy/env.prod.sample
@@ -116,3 +116,18 @@ RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f
# Typical non-docker setup:
# MUSIC_DIRECTORY_PATH=/srv/funkwhale/data/music
# # MUSIC_DIRECTORY_SERVE_PATH= # stays commented, not needed
+
+
+# LDAP settings
+# Use the following options to allow authentication on your Funkwhale instance
+# using a LDAP directory.
+# Have a look at https://docs.funkwhale.audio/installation/ldap.html for
+# detailed instructions.
+
+# LDAP_ENABLED=False
+# LDAP_SERVER_URI=ldap://your.server:389
+# LDAP_BIND_DN=cn=admin,dc=domain,dc=com
+# LDAP_BIND_PASSWORD=bindpassword
+# LDAP_SEARCH_FILTER=(|(cn={0})(mail={0}))
+# LDAP_START_TLS=False
+# LDAP_ROOT_DN=dc=domain,dc=com
diff --git a/docs/installation/external_dependencies.rst b/docs/installation/external_dependencies.rst
index 98199720..6156ed08 100644
--- a/docs/installation/external_dependencies.rst
+++ b/docs/installation/external_dependencies.rst
@@ -91,3 +91,8 @@ On Arch Linux and its derivatives:
sudo pacman -S redis
This should be enough to have your redis server set up.
+
+External Authentication (LDAP)
+----------------------
+
+LDAP support requires some additional dependencies to enable. On the OS level both ``libldap2-dev`` and ``libsasl2-dev`` are required, and the Python modules ``python-ldap`` and ``django-auth-ldap`` must be installed. These dependencies are all included in the ``requirements.*`` files so deploying with those will install these dependencies by default. However, they are not required unless LDAP support is explicitly enabled. See :doc:`./ldap` for more details.
diff --git a/docs/installation/ldap.rst b/docs/installation/ldap.rst
new file mode 100644
index 00000000..d38ba87a
--- /dev/null
+++ b/docs/installation/ldap.rst
@@ -0,0 +1,42 @@
+LDAP configuration
+==================
+
+LDAP is a protocol for providing directory services, in practice allowing a central authority for user login information.
+
+Funkwhale supports LDAP through the Django LDAP authentication module and by setting several configuration options.
+
+.. warning::
+
+ Note the following restrictions when using LDAP:
+
+ * LDAP-based users cannot change passwords inside the app.
+
+Dependencies
+------------
+
+LDAP support requires some additional dependencies to enable. On the OS level both ``libldap2-dev`` and ``libsasl2-dev`` are required, and the Python modules ``python-ldap`` and ``django-auth-ldap`` must be installed. These dependencies are all included in the ``requirements.*`` files so deploying with those will install these dependencies by default. However, they are not required unless LDAP support is explicitly enabled.
+
+Environment variables
+---------------------
+
+LDAP authentication is configured entirely through the environment variables. The following options enable the LDAP features:
+
+Basic features:
+
+* ``LDAP_ENABLED``: Set to ``True`` to enable LDAP support. Default: ``False``.
+* ``LDAP_SERVER_URI``: LDAP URI to the authentication server, e.g. ``ldap://my.host:389``.
+* ``LDAP_BIND_DN``: LDAP user DN to bind as to perform searches.
+* ``LDAP_BIND_PASSWORD``: LDAP user password for bind DN.
+* ``LDAP_SEARCH_FILTER``: The LDAP user filter, using ``{0}`` as the username placeholder, e.g. ``(|(cn={0})(mail={0}))``; uses standard LDAP search syntax. Default: ``(uid={0})``.
+* ``LDAP_START_TLS``: Set to ``True`` to enable LDAP StartTLS support. Default: ``False``.
+* ``LDAP_ROOT_DN``: The LDAP search root DN, e.g. ``dc=my,dc=domain,dc=com``; supports multiple entries in a space-delimited list, e.g. ``dc=users,dc=domain,dc=com dc=admins,dc=domain,dc=com``.
+* ``LDAP_USER_ATTR_MAP``: A mapping of Django user attributes to LDAP values, e.g. ``first_name:givenName, last_name:sn, username:cn, email:mail``. Default: ``first_name:givenName, last_name:sn, username:cn, email:mail``.
+
+Group features:
+
+For details on these options, see `the Django documentation <https://django-auth-ldap.readthedocs.io/en/latest/groups.html>`_. Group configuration is disabled unless an ``LDAP_GROUP_DN`` is set. This is an advanced LDAP feature and most users should not need to configure these settings.
+
+* ``LDAP_GROUP_DN``: The LDAP group search root DN, e.g. ``ou=groups,dc=domain,dc=com``.
+* ``LDAP_GROUP_FILTER``: The LDAP group filter, e.g. ``(objectClass=groupOfNames)``.
+* ``LDAP_REQUIRE_GROUP``: A group users must be a part of to authenticate, e.g. ``cn=enabled,ou=groups,dc=domain,dc=com``.
+* ``LDAP_DENY_GROUP``: A group users must not be a part of to authenticate, e.g. ``cn=disabled,ou=groups,dc=domain,dc=com``.
--
GitLab