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 d4833ab01c..f13026e261 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 57b7dfc7f8..c7a43c9401 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 1141579785..6a4430d8ae 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 b8df4bdb7c..f639fabd8f 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 71f2274642..c87bde6a5a 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 29f5663092..ae20ab1d8b 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 26ffb5a948..3ad56ea642 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 224ff955ae..6e4db7a3ba 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 7e7cb8a0d3..c173600a2f 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 bb441ac38d..fdd6f3d8f6 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 4f1f471d85..a99c71ffb0 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 0000000000..1010d02c84
--- /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 39a5bd3266..0d03c0fc2b 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 0000000000..736c8a24a3
--- /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 838e8fef4b..26702cfa7a 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 9819972073..6156ed0888 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 0000000000..d38ba87a14
--- /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