Implement LDAP authentication

merge-requests/399/head
Joshua M. Boniface 2018-08-22 18:10:39 +00:00 zatwierdzone przez Eliot Berriot
rodzic 6ed5740f6f
commit 4ce46ff2a0
17 zmienionych plików z 232 dodań i 15 usunięć

Wyświetl plik

@ -11,3 +11,4 @@ VUE_PORT=8080
MUSIC_DIRECTORY_PATH=/music
BROWSABLE_API_ENABLED=True
FORWARDED_PROTO=http
LDAP_ENABLED=False

Wyświetl plik

@ -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

Wyświetl plik

@ -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"

Wyświetl plik

@ -67,6 +67,7 @@ LOGGING = {
"propagate": True,
"level": "DEBUG",
},
"django_auth_ldap": {"handlers": ["console"], "level": "DEBUG"},
"": {"level": "DEBUG", "handlers": ["console"]},
},
}

Wyświetl plik

@ -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)

Wyświetl plik

@ -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")

Wyświetl plik

@ -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:

Wyświetl plik

@ -6,3 +6,5 @@ libmagic-dev
libpq-dev
postgresql-client
python3-dev
libldap2-dev
libsasl2-dev

Wyświetl plik

@ -4,3 +4,5 @@ ffmpeg
libjpeg-turbo
libpqxx
python
libldap
libsasl

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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},
)
)

Wyświetl plik

@ -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.

Wyświetl plik

@ -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

Wyświetl plik

@ -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.

Wyświetl plik

@ -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``.