Merge branch '194-ldap-auth' into 'develop'
Implement LDAP authentication Closes #194 See merge request funkwhale/funkwhale!382
This commit is contained in:
commit
3f75591779
1
.env.dev
1
.env.dev
|
@ -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"]},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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},
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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.
|
|
@ -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.
|
||||
|
|
|
@ -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``.
|
Loading…
Reference in New Issue