LDAP Provider: TLS support (#1137)

This commit is contained in:
Jens L 2021-07-13 18:24:18 +02:00 committed by GitHub
parent cd0a6f2d7c
commit 7dfc621ae4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 387 additions and 33 deletions

View File

@ -51,6 +51,7 @@ COPY --from=website-builder /static/build_docs/ /work/website/build_docs/
COPY ./cmd /work/cmd
COPY ./web/static.go /work/web/static.go
COPY ./website/static.go /work/website/static.go
COPY ./internal /work/internal
COPY ./go.mod /work/go.mod
COPY ./go.sum /work/go.sum

View File

@ -1,24 +1,60 @@
"""Groups API Viewset"""
from django.db.models.query import QuerySet
from rest_framework.fields import JSONField
from rest_framework.serializers import ModelSerializer
from rest_framework.fields import BooleanField, CharField, JSONField
from rest_framework.serializers import ListSerializer, ModelSerializer
from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import is_dict
from authentik.core.models import Group
from authentik.core.models import Group, User
class GroupMemberSerializer(ModelSerializer):
"""Stripped down user serializer to show relevant users for groups"""
is_superuser = BooleanField(read_only=True)
avatar = CharField(read_only=True)
attributes = JSONField(validators=[is_dict], required=False)
uid = CharField(read_only=True)
class Meta:
model = User
fields = [
"pk",
"username",
"name",
"is_active",
"last_login",
"is_superuser",
"email",
"avatar",
"attributes",
"uid",
]
class GroupSerializer(ModelSerializer):
"""Group Serializer"""
attributes = JSONField(validators=[is_dict], required=False)
users_obj = ListSerializer(
child=GroupMemberSerializer(), read_only=True, source="users", required=False
)
class Meta:
model = Group
fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"]
fields = [
"pk",
"name",
"is_superuser",
"parent",
"users",
"attributes",
"users_obj",
]
class GroupViewSet(UsedByMixin, ModelViewSet):

View File

@ -17,6 +17,8 @@ class LDAPProviderSerializer(ProviderSerializer):
fields = ProviderSerializer.Meta.fields + [
"base_dn",
"search_group",
"certificate",
"tls_server_name",
]
@ -44,6 +46,8 @@ class LDAPOutpostConfigSerializer(ModelSerializer):
"bind_flow_slug",
"application_slug",
"search_group",
"certificate",
"tls_server_name",
]

View File

@ -11,4 +11,5 @@ class LDAPDockerController(DockerController):
super().__init__(outpost, connection)
self.deployment_ports = [
DeploymentPort(389, "ldap", "tcp", 3389),
DeploymentPort(636, "ldaps", "tcp", 6636),
]

View File

@ -11,4 +11,5 @@ class LDAPKubernetesController(KubernetesController):
super().__init__(outpost, connection)
self.deployment_ports = [
DeploymentPort(389, "ldap", "tcp", 3389),
DeploymentPort(636, "ldaps", "tcp", 6636),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 3.2.5 on 2021-07-13 11:38
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_crypto", "0002_create_self_signed_kp"),
("authentik_providers_ldap", "0002_ldapprovider_search_group"),
]
operations = [
migrations.AddField(
model_name="ldapprovider",
name="certificate",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="authentik_crypto.certificatekeypair",
),
),
migrations.AddField(
model_name="ldapprovider",
name="tls_server_name",
field=models.TextField(blank=True, default=""),
),
]

View File

@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
from authentik.core.models import Group, Provider
from authentik.crypto.models import CertificateKeyPair
from authentik.outposts.models import OutpostModel
@ -28,6 +29,17 @@ class LDAPProvider(OutpostModel, Provider):
),
)
tls_server_name = models.TextField(
default="",
blank=True,
)
certificate = models.ForeignKey(
CertificateKeyPair,
on_delete=models.SET_NULL,
null=True,
blank=True,
)
@property
def launch_url(self) -> Optional[str]:
"""LDAP never has a launch URL"""

View File

@ -37,7 +37,7 @@ func GenerateSelfSignedCert() (tls.Certificate, error) {
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"authentik"},
CommonName: "authentik Proxy default certificate",
CommonName: "authentik Outpost default certificate",
},
NotBefore: notBefore,
NotAfter: notAfter,

View File

@ -1,6 +1,8 @@
package ak
import (
"context"
"crypto/tls"
"net/http"
"os"
"strings"
@ -9,6 +11,7 @@ import (
"github.com/getsentry/sentry-go"
httptransport "github.com/go-openapi/runtime/client"
log "github.com/sirupsen/logrus"
"goauthentik.io/outpost/api"
"goauthentik.io/outpost/pkg"
)
@ -66,3 +69,21 @@ func GetTLSTransport() http.RoundTripper {
}
return tlsTransport
}
// ParseCertificate Load certificate from Keyepair UUID and parse it into a go Certificate
func ParseCertificate(kpUuid string, cryptoApi *api.CryptoApiService) (*tls.Certificate, error) {
cert, _, err := cryptoApi.CryptoCertificatekeypairsViewCertificateRetrieve(context.Background(), kpUuid).Execute()
if err != nil {
return nil, err
}
key, _, err := cryptoApi.CryptoCertificatekeypairsViewPrivateKeyRetrieve(context.Background(), kpUuid).Execute()
if err != nil {
return nil, err
}
x509cert, err := tls.X509KeyPair([]byte(cert.Data), []byte(key.Data))
if err != nil {
return nil, err
}
return &x509cert, nil
}

View File

@ -2,6 +2,7 @@ package ldap
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
@ -10,6 +11,7 @@ import (
"github.com/go-openapi/strfmt"
log "github.com/sirupsen/logrus"
"goauthentik.io/outpost/pkg/ak"
)
func (ls *LDAPServer) Refresh() error {
@ -24,6 +26,7 @@ func (ls *LDAPServer) Refresh() error {
for idx, provider := range outposts.Results {
userDN := strings.ToLower(fmt.Sprintf("ou=users,%s", *provider.BaseDn))
groupDN := strings.ToLower(fmt.Sprintf("ou=groups,%s", *provider.BaseDn))
logger := log.WithField("logger", "authentik.outpost.ldap").WithField("provider", provider.Name)
providers[idx] = &ProviderInstance{
BaseDN: *provider.BaseDn,
GroupDN: groupDN,
@ -34,7 +37,18 @@ func (ls *LDAPServer) Refresh() error {
boundUsersMutex: sync.RWMutex{},
boundUsers: make(map[string]UserFlags),
s: ls,
log: log.WithField("logger", "authentik.outpost.ldap").WithField("provider", provider.Name),
log: logger,
tlsServerName: provider.TlsServerName,
}
if provider.Certificate.Get() != nil {
logger.WithField("provider", provider.Name).Debug("Enabling TLS")
cert, err := ak.ParseCertificate(*provider.Certificate.Get(), ls.ac.Client.CryptoApi)
if err != nil {
logger.WithField("provider", provider.Name).WithError(err).Warning("Failed to fetch certificate")
} else {
providers[idx].cert = cert
logger.WithField("provider", provider.Name).Debug("Loaded certificates")
}
}
}
ls.providers = providers
@ -58,9 +72,30 @@ func (ls *LDAPServer) StartLDAPServer() error {
return ls.s.ListenAndServe(listen)
}
func (ls *LDAPServer) StartLDAPTLSServer() error {
listen := "0.0.0.0:6636"
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS12,
GetCertificate: ls.getCertificates,
}
ln, err := tls.Listen("tcp", listen, tlsConfig)
if err != nil {
ls.log.Fatalf("FATAL: listen (%s) failed - %s", listen, err)
}
ls.log.WithField("listen", listen).Info("Starting ldap tls server")
err = ls.s.Serve(ln)
if err != nil {
return err
}
ls.log.Printf("closing %s", ln.Addr())
return ls.s.ListenAndServe(listen)
}
func (ls *LDAPServer) Start() error {
wg := sync.WaitGroup{}
wg.Add(2)
wg.Add(3)
go func() {
defer wg.Done()
err := ls.StartHTTPServer()
@ -75,6 +110,13 @@ func (ls *LDAPServer) Start() error {
panic(err)
}
}()
go func() {
defer wg.Done()
err := ls.StartLDAPTLSServer()
if err != nil {
panic(err)
}
}()
wg.Wait()
return nil
}

View File

@ -0,0 +1,23 @@
package ldap
import "crypto/tls"
func (ls *LDAPServer) getCertificates(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
if len(ls.providers) == 1 {
if ls.providers[0].cert != nil {
ls.log.WithField("server-name", info.ServerName).Debug("We only have a single provider, using their cert")
return ls.providers[0].cert, nil
}
}
for _, provider := range ls.providers {
if provider.tlsServerName == &info.ServerName {
if provider.cert == nil {
ls.log.WithField("server-name", info.ServerName).Debug("Handler does not have a certificate")
return ls.defaultCert, nil
}
return provider.cert, nil
}
}
ls.log.WithField("server-name", info.ServerName).Debug("Fallback to default cert")
return ls.defaultCert, nil
}

View File

@ -109,7 +109,7 @@ func (pi *ProviderInstance) UserEntry(u api.User) *ldap.Entry {
attrs = append(attrs, AKAttrsToLDAP(u.Attributes)...)
dn := fmt.Sprintf("cn=%s,%s", u.Username, pi.UserDN)
dn := pi.GetUserDN(u.Username)
return &ldap.Entry{DN: dn, Attributes: attrs}
}
@ -129,6 +129,9 @@ func (pi *ProviderInstance) GroupEntry(g api.Group) *ldap.Entry {
Values: []string{GroupObjectClass, "goauthentik.io/ldap/group"},
},
}
attrs = append(attrs, &ldap.EntryAttribute{Name: "member", Values: pi.UsersForGroup(g)})
attrs = append(attrs, &ldap.EntryAttribute{Name: "goauthentik.io/ldap/superuser", Values: []string{BoolToString(*g.IsSuperuser)}})
attrs = append(attrs, AKAttrsToLDAP(g.Attributes)...)
dn := pi.GetGroupDN(g)

View File

@ -1,6 +1,7 @@
package ldap
import (
"crypto/tls"
"sync"
"github.com/go-openapi/strfmt"
@ -25,6 +26,9 @@ type ProviderInstance struct {
s *LDAPServer
log *log.Entry
tlsServerName *string
cert *tls.Certificate
searchAllowedGroups []*strfmt.UUID
boundUsersMutex sync.RWMutex
boundUsers map[string]UserFlags
@ -36,11 +40,11 @@ type UserFlags struct {
}
type LDAPServer struct {
s *ldap.Server
log *log.Entry
ac *ak.APIController
providers []*ProviderInstance
s *ldap.Server
log *log.Entry
ac *ak.APIController
defaultCert *tls.Certificate
providers []*ProviderInstance
}
func NewServer(ac *ak.APIController) *LDAPServer {
@ -52,6 +56,11 @@ func NewServer(ac *ak.APIController) *LDAPServer {
ac: ac,
providers: []*ProviderInstance{},
}
defaultCert, err := ak.GenerateSelfSignedCert()
if err != nil {
log.Warning(err)
}
ls.defaultCert = &defaultCert
s.BindFunc("", ls)
s.SearchFunc("", ls)
return ls

View File

@ -2,8 +2,10 @@ package ldap
import (
"fmt"
"reflect"
"github.com/nmcclain/ldap"
log "github.com/sirupsen/logrus"
"goauthentik.io/outpost/api"
)
@ -14,6 +16,24 @@ func BoolToString(in bool) string {
return "false"
}
func ldapResolveTypeSingle(in interface{}) *string {
switch t := in.(type) {
case string:
return &t
case *string:
return t
case bool:
s := BoolToString(t)
return &s
case *bool:
s := BoolToString(*t)
return &s
default:
log.WithField("type", reflect.TypeOf(in).String()).Warning("Type can't be mapped to LDAP yet")
return nil
}
}
func AKAttrsToLDAP(attrs interface{}) []*ldap.EntryAttribute {
attrList := []*ldap.EntryAttribute{}
a := attrs.(*map[string]interface{})
@ -22,10 +42,19 @@ func AKAttrsToLDAP(attrs interface{}) []*ldap.EntryAttribute {
switch t := attrValue.(type) {
case []string:
entry.Values = t
case string:
entry.Values = []string{t}
case bool:
entry.Values = []string{BoolToString(t)}
case *[]string:
entry.Values = *t
case []interface{}:
entry.Values = make([]string, len(t))
for idx, v := range t {
v := ldapResolveTypeSingle(v)
entry.Values[idx] = *v
}
default:
v := ldapResolveTypeSingle(t)
if v != nil {
entry.Values = []string{*v}
}
}
attrList = append(attrList, entry)
}
@ -40,6 +69,18 @@ func (pi *ProviderInstance) GroupsForUser(user api.User) []string {
return groups
}
func (pi *ProviderInstance) UsersForGroup(group api.Group) []string {
users := make([]string, len(group.UsersObj))
for i, user := range group.UsersObj {
users[i] = pi.GetUserDN(user.Username)
}
return users
}
func (pi *ProviderInstance) GetUserDN(user string) string {
return fmt.Sprintf("cn=%s,%s", user, pi.UserDN)
}
func (pi *ProviderInstance) GetGroupDN(group api.Group) string {
return fmt.Sprintf("cn=%s,%s", group.Name, pi.GroupDN)
}

View File

@ -1,7 +1,6 @@
package proxy
import (
"context"
"crypto/tls"
"net"
"net/http"
@ -16,6 +15,7 @@ import (
"github.com/oauth2-proxy/oauth2-proxy/pkg/validation"
log "github.com/sirupsen/logrus"
"goauthentik.io/outpost/api"
"goauthentik.io/outpost/pkg/ak"
)
type providerBundle struct {
@ -90,23 +90,12 @@ func (pb *providerBundle) prepareOpts(provider api.ProxyOutpostConfig) *options.
if provider.Certificate.Get() != nil {
pb.log.WithField("provider", provider.Name).Debug("Enabling TLS")
cert, _, err := pb.s.ak.Client.CryptoApi.CryptoCertificatekeypairsViewCertificateRetrieve(context.Background(), *provider.Certificate.Get()).Execute()
cert, err := ak.ParseCertificate(*provider.Certificate.Get(), pb.s.ak.Client.CryptoApi)
if err != nil {
pb.log.WithField("provider", provider.Name).WithError(err).Warning("Failed to fetch certificate")
return providerOpts
}
key, _, err := pb.s.ak.Client.CryptoApi.CryptoCertificatekeypairsViewPrivateKeyRetrieve(context.Background(), *provider.Certificate.Get()).Execute()
if err != nil {
pb.log.WithField("provider", provider.Name).WithError(err).Warning("Failed to fetch private key")
return providerOpts
}
x509cert, err := tls.X509KeyPair([]byte(cert.Data), []byte(key.Data))
if err != nil {
pb.log.WithField("provider", provider.Name).WithError(err).Warning("Failed to parse certificate")
return providerOpts
}
pb.cert = &x509cert
pb.cert = cert
pb.log.WithField("provider", provider.Name).Debug("Loaded certificates")
}
return providerOpts

View File

@ -19927,11 +19927,100 @@ components:
attributes:
type: object
additionalProperties: {}
users_obj:
type: array
items:
$ref: '#/components/schemas/GroupMember'
readOnly: true
required:
- name
- parent
- pk
- users
- users_obj
GroupMember:
type: object
description: Stripped down user serializer to show relevant users for groups
properties:
pk:
type: integer
readOnly: true
title: ID
username:
type: string
description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_
only.
pattern: ^[\w.@+-]+$
maxLength: 150
name:
type: string
description: User's display name.
is_active:
type: boolean
title: Active
description: Designates whether this user should be treated as active. Unselect
this instead of deleting accounts.
last_login:
type: string
format: date-time
nullable: true
is_superuser:
type: boolean
readOnly: true
email:
type: string
format: email
title: Email address
maxLength: 254
avatar:
type: string
readOnly: true
attributes:
type: object
additionalProperties: {}
uid:
type: string
readOnly: true
required:
- avatar
- is_superuser
- name
- pk
- uid
- username
GroupMemberRequest:
type: object
description: Stripped down user serializer to show relevant users for groups
properties:
username:
type: string
description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_
only.
pattern: ^[\w.@+-]+$
maxLength: 150
name:
type: string
description: User's display name.
is_active:
type: boolean
title: Active
description: Designates whether this user should be treated as active. Unselect
this instead of deleting accounts.
last_login:
type: string
format: date-time
nullable: true
email:
type: string
format: email
title: Email address
maxLength: 254
attributes:
type: object
additionalProperties: {}
required:
- name
- username
GroupRequest:
type: object
description: Group Serializer
@ -20402,6 +20491,12 @@ components:
nullable: true
description: Users in this group can do search queries. If not set, every
user can execute search queries.
certificate:
type: string
format: uuid
nullable: true
tls_server_name:
type: string
required:
- application_slug
- bind_flow_slug
@ -20514,6 +20609,12 @@ components:
nullable: true
description: Users in this group can do search queries. If not set, every
user can execute search queries.
certificate:
type: string
format: uuid
nullable: true
tls_server_name:
type: string
required:
- assigned_application_name
- assigned_application_slug
@ -20547,6 +20648,12 @@ components:
nullable: true
description: Users in this group can do search queries. If not set, every
user can execute search queries.
certificate:
type: string
format: uuid
nullable: true
tls_server_name:
type: string
required:
- authorization_flow
- name
@ -24883,6 +24990,12 @@ components:
nullable: true
description: Users in this group can do search queries. If not set, every
user can execute search queries.
certificate:
type: string
format: uuid
nullable: true
tls_server_name:
type: string
PatchedLDAPSourceRequest:
type: object
description: LDAP Source Serializer

View File

@ -1,4 +1,4 @@
import { FlowsApi, ProvidersApi, LDAPProvider, CoreApi, FlowsInstancesListDesignationEnum } from "authentik-api";
import { FlowsApi, ProvidersApi, LDAPProvider, CoreApi, FlowsInstancesListDesignationEnum, CryptoApi } from "authentik-api";
import { t } from "@lingui/macro";
import { customElement } from "lit-element";
import { html, TemplateResult } from "lit-html";
@ -90,6 +90,27 @@ export class LDAPProviderFormPage extends ModelForm<LDAPProvider, number> {
<input type="text" value="${first(this.instance?.baseDn, "DC=ldap,DC=goauthentik,DC=io")}" class="pf-c-form-control" required>
<p class="pf-c-form__helper-text">${t`LDAP DN under which bind requests and search requests can be made.`}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`TLS Server name`}
name="baseDn">
<input type="text" value="${first(this.instance?.tlsServerName, "")}" class="pf-c-form-control">
<p class="pf-c-form__helper-text">${t`Server name for which this provider's certificate is valid for.`}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Certificate`}
name="certificate">
<select class="pf-c-form-control">
<option value="" ?selected=${this.instance?.certificate === undefined}>---------</option>
${until(new CryptoApi(DEFAULT_CONFIG).cryptoCertificatekeypairsList({
ordering: "pk",
hasKey: true,
}).then(keys => {
return keys.results.map(key => {
return html`<option value=${ifDefined(key.pk)} ?selected=${this.instance?.certificate === key.pk}>${key.name}</option>`;
});
}), html`<option>${t`Loading...`}</option>`)}
</select>
</ak-form-element-horizontal>
</div>
</ak-form-group>
</form>`;

View File

@ -22,7 +22,7 @@ You can bind using the DN `cn=<username>,ou=users,<base DN>`, or using the follo
ldapsearch \
-x \ # Only simple binds are currently supported
-h *ip* \
-p 3389 \
-p 389 \
-D 'cn=*user*,ou=users,DC=ldap,DC=goauthentik,DC=io' \ # Bind user and password
-w '*password*' \
-b 'ou=users,DC=ldap,DC=goauthentik,DC=io' \ # The search base
@ -48,8 +48,15 @@ The following fields are current set for groups:
- `cn`: The group's name
- `uid`: Unique group identifier
- `member`: A list of all DNs of the group's members
- `objectClass`: A list of these strings:
- "group"
- "goauthentik.io/ldap/group"
**Additionally**, for both users and groups, any attributes you set are also present as LDAP Attributes.
## SSL
You can also configure SSL for your LDAP Providers by selecting a certificate and a server name in the provider settings.
This enables you to bind on port 636 using LDAPS, StartTLS is not supported.