Skip to content
Snippets Groups Projects
Commit 3f755917 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch '194-ldap-auth' into 'develop'

Implement LDAP authentication

Closes #194

See merge request funkwhale/funkwhale!382
parents 6ed5740f 4ce46ff2
No related branches found
No related tags found
No related merge requests found
Showing
with 232 additions and 15 deletions
......@@ -11,3 +11,4 @@ VUE_PORT=8080
MUSIC_DIRECTORY_PATH=/music
BROWSABLE_API_ENABLED=True
FORWARDED_PROTO=http
LDAP_ENABLED=False
......@@ -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
......
......@@ -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"
......
......@@ -67,6 +67,7 @@ LOGGING = {
"propagate": True,
"level": "DEBUG",
},
"django_auth_ldap": {"handlers": ["console"], "level": "DEBUG"},
"": {"level": "DEBUG", "handlers": ["console"]},
},
}
......
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)
......@@ -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")
......
......@@ -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:
......
......@@ -6,3 +6,5 @@ libmagic-dev
libpq-dev
postgresql-client
python3-dev
libldap2-dev
libsasl2-dev
......@@ -4,3 +4,5 @@ ffmpeg
libjpeg-turbo
libpqxx
python
libldap
libsasl
......@@ -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
......@@ -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)
......
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
......@@ -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},
)
)
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.
......@@ -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
......@@ -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.
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``.
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment