Implement LDAP authentication

This commit is contained in:
Joshua M. Boniface 2018-08-22 18:10:39 +00:00 committed by Eliot Berriot
parent 6ed5740f6f
commit 4ce46ff2a0
17 changed files with 232 additions and 15 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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