Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sources/ldap: add support for cert based auth #5850

Merged
merged 14 commits into from Jun 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Expand Up @@ -151,7 +151,7 @@ web-check-compile:
cd web && npm run tsc

web-i18n-extract:
cd web && npm run extract
cd web && npm run extract-locales

#########################
## Website
Expand Down
15 changes: 15 additions & 0 deletions authentik/sources/ldap/api.py
Expand Up @@ -8,6 +8,7 @@
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import DictField, ListField
from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
Expand All @@ -16,6 +17,7 @@
from authentik.core.api.propertymappings import PropertyMappingSerializer
from authentik.core.api.sources import SourceSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.crypto.models import CertificateKeyPair
from authentik.events.monitored_tasks import TaskInfo
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
from authentik.sources.ldap.tasks import SYNC_CLASSES
Expand All @@ -24,6 +26,15 @@
class LDAPSourceSerializer(SourceSerializer):
"""LDAP Source Serializer"""

client_certificate = PrimaryKeyRelatedField(
allow_null=True,
help_text="Client certificate to authenticate against the LDAP Server's Certificate.",
queryset=CertificateKeyPair.objects.exclude(
key_data__exact="",
),
required=False,
)

def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
"""Check that only a single source has password_sync on"""
sync_users_password = attrs.get("sync_users_password", True)
Expand All @@ -42,9 +53,11 @@ class Meta:
fields = SourceSerializer.Meta.fields + [
"server_uri",
"peer_certificate",
"client_certificate",
"bind_cn",
"bind_password",
"start_tls",
"sni",
"base_dn",
"additional_user_dn",
"additional_group_dn",
Expand Down Expand Up @@ -75,7 +88,9 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
"server_uri",
"bind_cn",
"peer_certificate",
"client_certificate",
"start_tls",
"sni",
"base_dn",
"additional_user_dn",
"additional_group_dn",
Expand Down
@@ -0,0 +1,45 @@
# Generated by Django 4.1.7 on 2023-06-06 18:33

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("authentik_crypto", "0004_alter_certificatekeypair_name"),
("authentik_sources_ldap", "0002_auto_20211203_0900"),
]

operations = [
migrations.AddField(
model_name="ldapsource",
name="client_certificate",
field=models.ForeignKey(
default=None,
help_text="Client certificate to authenticate against the LDAP Server's Certificate.",
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
related_name="ldap_client_certificates",
to="authentik_crypto.certificatekeypair",
),
),
migrations.AddField(
model_name="ldapsource",
name="sni",
field=models.BooleanField(
default=False, verbose_name="Use Server URI for SNI verification"
),
),
migrations.AlterField(
model_name="ldapsource",
name="peer_certificate",
field=models.ForeignKey(
default=None,
help_text="Optionally verify the LDAP Server's Certificate against the CA Chain in this keypair.",
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
related_name="ldap_peer_certificates",
to="authentik_crypto.certificatekeypair",
),
),
]
37 changes: 33 additions & 4 deletions authentik/sources/ldap/models.py
@@ -1,11 +1,13 @@
"""authentik LDAP Models"""
from os import chmod
from ssl import CERT_REQUIRED
from tempfile import NamedTemporaryFile, mkdtemp
from typing import Optional

from django.db import models
from django.utils.translation import gettext_lazy as _
from ldap3 import ALL, NONE, RANDOM, Connection, Server, ServerPool, Tls
from ldap3.core.exceptions import LDAPSchemaError
from ldap3.core.exceptions import LDAPInsufficientAccessRightsResult, LDAPSchemaError
from rest_framework.serializers import Serializer

from authentik.core.models import Group, PropertyMapping, Source
Expand Down Expand Up @@ -39,14 +41,24 @@
on_delete=models.SET_DEFAULT,
default=None,
null=True,
related_name="ldap_peer_certificates",
help_text=_(
"Optionally verify the LDAP Server's Certificate against the CA Chain in this keypair."
),
)
client_certificate = models.ForeignKey(
CertificateKeyPair,
on_delete=models.SET_DEFAULT,
default=None,
null=True,
related_name="ldap_client_certificates",
help_text=_("Client certificate to authenticate against the LDAP Server's Certificate."),
)

bind_cn = models.TextField(verbose_name=_("Bind CN"), blank=True)
bind_password = models.TextField(blank=True)
start_tls = models.BooleanField(default=False, verbose_name=_("Enable Start TLS"))
sni = models.BooleanField(default=False, verbose_name=_("Use Server URI for SNI verification"))

base_dn = models.TextField(verbose_name=_("Base DN"))
additional_user_dn = models.TextField(
Expand Down Expand Up @@ -112,8 +124,22 @@
if self.peer_certificate:
tls_kwargs["ca_certs_data"] = self.peer_certificate.certificate_data
tls_kwargs["validate"] = CERT_REQUIRED
if self.client_certificate:
temp_dir = mkdtemp()
with NamedTemporaryFile(mode="w", delete=False, dir=temp_dir) as temp_cert:
temp_cert.write(self.client_certificate.certificate_data)
certificate_file = temp_cert.name
chmod(certificate_file, 0o600)
with NamedTemporaryFile(mode="w", delete=False, dir=temp_dir) as temp_key:
temp_key.write(self.client_certificate.key_data)
private_key_file = temp_key.name
chmod(private_key_file, 0o600)
tls_kwargs["local_private_key_file"] = private_key_file
tls_kwargs["local_certificate_file"] = certificate_file

Check warning on line 138 in authentik/sources/ldap/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/sources/ldap/models.py#L127-L138

Added lines #L127 - L138 were not covered by tests
if ciphers := CONFIG.y("ldap.tls.ciphers", None):
tls_kwargs["ciphers"] = ciphers.strip()
if self.sni:
tls_kwargs["sni"] = self.server_uri.split(",", maxsplit=1)[0].strip()

Check warning on line 142 in authentik/sources/ldap/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/sources/ldap/models.py#L141-L142

Added lines #L141 - L142 were not covered by tests
server_kwargs = {
"get_info": ALL,
"connect_timeout": LDAP_TIMEOUT,
Expand All @@ -133,8 +159,10 @@
"""Get a fully connected and bound LDAP Connection"""
server_kwargs = server_kwargs or {}
connection_kwargs = connection_kwargs or {}
connection_kwargs.setdefault("user", self.bind_cn)
connection_kwargs.setdefault("password", self.bind_password)
if self.bind_cn is not None:
connection_kwargs.setdefault("user", self.bind_cn)
if self.bind_password is not None:
connection_kwargs.setdefault("password", self.bind_password)

Check warning on line 165 in authentik/sources/ldap/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/sources/ldap/models.py#L162-L165

Added lines #L162 - L165 were not covered by tests
connection = Connection(
self.server(**server_kwargs),
raise_exceptions=True,
Expand All @@ -148,9 +176,10 @@
successful = connection.bind()
if successful:
return connection
except LDAPSchemaError as exc:
except (LDAPSchemaError, LDAPInsufficientAccessRightsResult) as exc:

Check warning on line 179 in authentik/sources/ldap/models.py

View check run for this annotation

Codecov / codecov/patch

authentik/sources/ldap/models.py#L179

Added line #L179 was not covered by tests
# Schema error, so try connecting without schema info
# See https://github.com/goauthentik/authentik/issues/4590
# See also https://github.com/goauthentik/authentik/issues/3399
if server_kwargs.get("get_info", ALL) == NONE:
raise exc
server_kwargs["get_info"] = NONE
Expand Down