diff --git a/Dockerfile b/Dockerfile index 760de54f9..a6bd54b8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/authentik/core/api/groups.py b/authentik/core/api/groups.py index d003d9fd2..d08731756 100644 --- a/authentik/core/api/groups.py +++ b/authentik/core/api/groups.py @@ -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): diff --git a/authentik/providers/ldap/api.py b/authentik/providers/ldap/api.py index 5c10eb6ea..a0612b1ea 100644 --- a/authentik/providers/ldap/api.py +++ b/authentik/providers/ldap/api.py @@ -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", ] diff --git a/authentik/providers/ldap/controllers/docker.py b/authentik/providers/ldap/controllers/docker.py index 0819c6a58..27d972917 100644 --- a/authentik/providers/ldap/controllers/docker.py +++ b/authentik/providers/ldap/controllers/docker.py @@ -11,4 +11,5 @@ class LDAPDockerController(DockerController): super().__init__(outpost, connection) self.deployment_ports = [ DeploymentPort(389, "ldap", "tcp", 3389), + DeploymentPort(636, "ldaps", "tcp", 6636), ] diff --git a/authentik/providers/ldap/controllers/kubernetes.py b/authentik/providers/ldap/controllers/kubernetes.py index 4c5176d9c..e267979db 100644 --- a/authentik/providers/ldap/controllers/kubernetes.py +++ b/authentik/providers/ldap/controllers/kubernetes.py @@ -11,4 +11,5 @@ class LDAPKubernetesController(KubernetesController): super().__init__(outpost, connection) self.deployment_ports = [ DeploymentPort(389, "ldap", "tcp", 3389), + DeploymentPort(636, "ldaps", "tcp", 6636), ] diff --git a/authentik/providers/ldap/migrations/0003_auto_20210713_1138.py b/authentik/providers/ldap/migrations/0003_auto_20210713_1138.py new file mode 100644 index 000000000..bc76c6ca3 --- /dev/null +++ b/authentik/providers/ldap/migrations/0003_auto_20210713_1138.py @@ -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=""), + ), + ] diff --git a/authentik/providers/ldap/models.py b/authentik/providers/ldap/models.py index fe970080b..7cf6630e6 100644 --- a/authentik/providers/ldap/models.py +++ b/authentik/providers/ldap/models.py @@ -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""" diff --git a/outpost/pkg/ak/cert.go b/outpost/pkg/ak/cert.go index 69c39f6bf..253a78ef0 100644 --- a/outpost/pkg/ak/cert.go +++ b/outpost/pkg/ak/cert.go @@ -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, diff --git a/outpost/pkg/ak/global.go b/outpost/pkg/ak/global.go index 51ee640cd..996ee5618 100644 --- a/outpost/pkg/ak/global.go +++ b/outpost/pkg/ak/global.go @@ -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 +} diff --git a/outpost/pkg/ldap/api.go b/outpost/pkg/ldap/api.go index 85b4837cd..dcfde8fdb 100644 --- a/outpost/pkg/ldap/api.go +++ b/outpost/pkg/ldap/api.go @@ -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 } diff --git a/outpost/pkg/ldap/api_tls.go b/outpost/pkg/ldap/api_tls.go new file mode 100644 index 000000000..0bcbdf7d2 --- /dev/null +++ b/outpost/pkg/ldap/api_tls.go @@ -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 +} diff --git a/outpost/pkg/ldap/instance_search.go b/outpost/pkg/ldap/instance_search.go index 34cfb3f6c..2f0360511 100644 --- a/outpost/pkg/ldap/instance_search.go +++ b/outpost/pkg/ldap/instance_search.go @@ -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) diff --git a/outpost/pkg/ldap/ldap.go b/outpost/pkg/ldap/ldap.go index 3ad2a1d40..0a8ad7505 100644 --- a/outpost/pkg/ldap/ldap.go +++ b/outpost/pkg/ldap/ldap.go @@ -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 diff --git a/outpost/pkg/ldap/utils.go b/outpost/pkg/ldap/utils.go index 8252bf80e..685efa487 100644 --- a/outpost/pkg/ldap/utils.go +++ b/outpost/pkg/ldap/utils.go @@ -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) } diff --git a/outpost/pkg/proxy/api_bundle.go b/outpost/pkg/proxy/api_bundle.go index 1489c3c13..3a5997ad5 100644 --- a/outpost/pkg/proxy/api_bundle.go +++ b/outpost/pkg/proxy/api_bundle.go @@ -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 diff --git a/schema.yml b/schema.yml index 40a862295..623010402 100644 --- a/schema.yml +++ b/schema.yml @@ -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 diff --git a/web/src/pages/providers/ldap/LDAPProviderForm.ts b/web/src/pages/providers/ldap/LDAPProviderForm.ts index 0be9e4075..3fe4c22e3 100644 --- a/web/src/pages/providers/ldap/LDAPProviderForm.ts +++ b/web/src/pages/providers/ldap/LDAPProviderForm.ts @@ -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 {

${t`LDAP DN under which bind requests and search requests can be made.`}

+ + +

${t`Server name for which this provider's certificate is valid for.`}

+
+ + + `; diff --git a/website/docs/outposts/ldap/ldap.md b/website/docs/outposts/ldap/ldap.md index 5e3af2469..7e8d92214 100644 --- a/website/docs/outposts/ldap/ldap.md +++ b/website/docs/outposts/ldap/ldap.md @@ -22,7 +22,7 @@ You can bind using the DN `cn=,ou=users,`, 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.