diff --git a/.env.dev b/.env.dev index d4833ab01..f13026e26 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 57b7dfc7f..c7a43c940 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 114157978..6a4430d8a 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 b8df4bdb7..f639fabd8 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 71f227464..c87bde6a5 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 29f566309..ae20ab1d8 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 26ffb5a94..3ad56ea64 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 224ff955a..6e4db7a3b 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 7e7cb8a0d..c173600a2 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 bb441ac38..fdd6f3d8f 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 4f1f471d8..a99c71ffb 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 000000000..1010d02c8 --- /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 39a5bd326..0d03c0fc2 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 000000000..736c8a24a --- /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 838e8fef4..26702cfa7 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 981997207..6156ed088 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 000000000..d38ba87a1 --- /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 `_. 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``.