diff --git a/authentik/outposts/models.py b/authentik/outposts/models.py index 33e653d5e..e52fc9ab9 100644 --- a/authentik/outposts/models.py +++ b/authentik/outposts/models.py @@ -77,6 +77,7 @@ class OutpostType(models.TextChoices): """Outpost types, currently only the reverse proxy is available""" PROXY = "proxy" + LDAP = "ldap" def default_outpost_config(host: Optional[str] = None): diff --git a/outpost/cmd/ldap/server.go b/outpost/cmd/ldap/server.go new file mode 100644 index 000000000..181a4eafb --- /dev/null +++ b/outpost/cmd/ldap/server.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + "math/rand" + "net/url" + "os" + "os/signal" + "time" + + log "github.com/sirupsen/logrus" + + "goauthentik.io/outpost/pkg/ak" + "goauthentik.io/outpost/pkg/ldap" +) + +const helpMessage = `authentik ldap + +Required environment variables: +- AUTHENTIK_HOST: URL to connect to (format "http://authentik.company") +- AUTHENTIK_TOKEN: Token to authenticate with +- AUTHENTIK_INSECURE: Skip SSL Certificate verification` + +func main() { + log.SetLevel(log.DebugLevel) + pbURL, found := os.LookupEnv("AUTHENTIK_HOST") + if !found { + fmt.Println("env AUTHENTIK_HOST not set!") + fmt.Println(helpMessage) + os.Exit(1) + } + pbToken, found := os.LookupEnv("AUTHENTIK_TOKEN") + if !found { + fmt.Println("env AUTHENTIK_TOKEN not set!") + fmt.Println(helpMessage) + os.Exit(1) + } + + pbURLActual, err := url.Parse(pbURL) + if err != nil { + fmt.Println(err) + fmt.Println(helpMessage) + os.Exit(1) + } + + rand.Seed(time.Now().UnixNano()) + + ac := ak.NewAPIController(*pbURLActual, pbToken) + + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, os.Interrupt) + + ac.Server = ldap.NewServer(ac) + + ac.Start() + + for { + select { + case <-interrupt: + ac.Shutdown() + os.Exit(0) + } + } +} diff --git a/outpost/go.mod b/outpost/go.mod index 3d83c99e6..f9089d9a2 100644 --- a/outpost/go.mod +++ b/outpost/go.mod @@ -20,6 +20,8 @@ require ( github.com/kr/pretty v0.2.1 // indirect github.com/magiconair/properties v1.8.5 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 // indirect + github.com/nmcclain/ldap v0.0.0-20191021200707-3b3b69a7e9e3 // indirect github.com/oauth2-proxy/oauth2-proxy v0.0.0-20200831161845-e4e5580852dc github.com/pelletier/go-toml v1.9.0 // indirect github.com/pkg/errors v0.9.1 diff --git a/outpost/go.sum b/outpost/go.sum index 1b567c857..9d458aa6e 100644 --- a/outpost/go.sum +++ b/outpost/go.sum @@ -483,6 +483,10 @@ github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxzi github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 h1:D9EvfGQvlkKaDr2CRKN++7HbSXbefUNDrPq60T+g24s= +github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484/go.mod h1:O1EljZ+oHprtxDDPHiMWVo/5dBT6PlvWX5PSwj80aBA= +github.com/nmcclain/ldap v0.0.0-20191021200707-3b3b69a7e9e3 h1:NNis9uuNpG5h97Dvxxo53Scg02qBg+3Nfabg6zjFGu8= +github.com/nmcclain/ldap v0.0.0-20191021200707-3b3b69a7e9e3/go.mod h1:YtrVB1/v9Td9SyjXpjYVmbdKgj9B0nPTBsdGUxy0i8U= github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/oauth2-proxy/oauth2-proxy v0.0.0-20200831161845-e4e5580852dc h1:jf/4meI7lkRwGoiD7Ex/ns0BekEPKZ8nsB3u2oLhLGM= diff --git a/outpost/outpost b/outpost/outpost new file mode 100755 index 000000000..2dc03909f Binary files /dev/null and b/outpost/outpost differ diff --git a/outpost/pkg/ak/global.go b/outpost/pkg/ak/global.go index f6678f6b9..b9512fc90 100644 --- a/outpost/pkg/ak/global.go +++ b/outpost/pkg/ak/global.go @@ -31,7 +31,7 @@ func doGlobalSetup(config map[string]interface{}) { default: log.SetLevel(log.DebugLevel) } - log.WithField("version", pkg.VERSION).Info("Starting authentik proxy") + log.WithField("version", pkg.VERSION).Info("Starting authentik outpost") var dsn string if config[ConfigErrorReportingEnabled].(bool) { diff --git a/outpost/pkg/ldap/api.go b/outpost/pkg/ldap/api.go new file mode 100644 index 000000000..c5c7eb69c --- /dev/null +++ b/outpost/pkg/ldap/api.go @@ -0,0 +1,20 @@ +package ldap + +import ( + log "github.com/sirupsen/logrus" +) + +func (ls *LDAPServer) Refresh() error { + return nil +} + +func (ls *LDAPServer) Start() error { + listen := "0.0.0.0:3389" + log.Debugf("Listening on %s", listen) + err := ls.s.ListenAndServe(listen) + if err != nil { + ls.log.Errorf("LDAP Server Failed: %s", err.Error()) + return err + } + return nil +} diff --git a/outpost/pkg/ldap/bind.go b/outpost/pkg/ldap/bind.go new file mode 100644 index 000000000..745ef959c --- /dev/null +++ b/outpost/pkg/ldap/bind.go @@ -0,0 +1,12 @@ +package ldap + +import ( + "net" + + "github.com/nmcclain/ldap" +) + +func (ls *LDAPServer) Bind(bindDN string, bindSimplePw string, conn net.Conn) (ldap.LDAPResultCode, error) { + ls.log.WithField("dn", bindDN).WithField("pw", bindSimplePw).Debug("bind") + return ldap.LDAPResultSuccess, nil +} diff --git a/outpost/pkg/ldap/ldap.go b/outpost/pkg/ldap/ldap.go new file mode 100644 index 000000000..105f2be4f --- /dev/null +++ b/outpost/pkg/ldap/ldap.go @@ -0,0 +1,42 @@ +package ldap + +import ( + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + "goauthentik.io/outpost/pkg/ak" + + "github.com/nmcclain/ldap" +) + +const GroupObjectClass = "group" +const UserObjectClass = "user" + +type LDAPServer struct { + BaseDN string + + userDN string + groupDN string + + s *ldap.Server + log *log.Entry + ac *ak.APIController +} + +func NewServer(ac *ak.APIController) *LDAPServer { + s := ldap.NewServer() + s.EnforceLDAP = true + ls := &LDAPServer{ + s: s, + log: log.WithField("logger", "ldap-server"), + ac: ac, + + BaseDN: "DC=ldap,DC=goauthentik,DC=io", + } + ls.userDN = strings.ToLower(fmt.Sprintf("cn=users,%s", ls.BaseDN)) + ls.groupDN = strings.ToLower(fmt.Sprintf("cn=groups,%s", ls.BaseDN)) + s.BindFunc("", ls) + s.SearchFunc("", ls) + return ls +} diff --git a/outpost/pkg/ldap/search.go b/outpost/pkg/ldap/search.go new file mode 100644 index 000000000..b0f818ddf --- /dev/null +++ b/outpost/pkg/ldap/search.go @@ -0,0 +1,115 @@ +package ldap + +import ( + "fmt" + "net" + "strconv" + "strings" + + "github.com/nmcclain/ldap" + "goauthentik.io/outpost/pkg/client/core" +) + +func (ls *LDAPServer) Search(bindDN string, searchReq ldap.SearchRequest, conn net.Conn) (ldap.ServerSearchResult, error) { + bindDN = strings.ToLower(bindDN) + baseDN := strings.ToLower("," + ls.BaseDN) + + entries := []*ldap.Entry{} + filterEntity, err := ldap.GetFilterObjectClass(searchReq.Filter) + if err != nil { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: error parsing filter: %s", searchReq.Filter) + } + if len(bindDN) < 1 { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: Anonymous BindDN not allowed %s", bindDN) + } + if !strings.HasSuffix(bindDN, baseDN) { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultInsufficientAccessRights}, fmt.Errorf("Search Error: BindDN %s not in our BaseDN %s", bindDN, ls.BaseDN) + } + + switch filterEntity { + default: + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("Search Error: unhandled filter type: %s [%s]", filterEntity, searchReq.Filter) + case GroupObjectClass: + groups, err := ls.ac.Client.Core.CoreGroupsList(core.NewCoreGroupsListParams(), ls.ac.Auth) + if err != nil { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("API Error: %s", err) + } + for _, g := range groups.Payload.Results { + attrs := []*ldap.EntryAttribute{ + { + Name: "cn", + Values: []string{*g.Name}, + }, + { + Name: "uid", + Values: []string{strconv.Itoa(int(g.Pk))}, + }, + { + Name: "objectClass", + Values: []string{GroupObjectClass, "goauthentik.io/ldap/group"}, + }, + } + attrs = append(attrs, AKAttrsToLDAP(g.Attributes)...) + // attrs = append(attrs, &ldap.EntryAttribute{Name: "description", Values: []string{fmt.Sprintf("%s", g.Name)}}) + // attrs = append(attrs, &ldap.EntryAttribute{Name: "gidNumber", Values: []string{fmt.Sprintf("%d", g.UnixID)}}) + // attrs = append(attrs, &ldap.EntryAttribute{Name: "uniqueMember", Values: h.getGroupMembers(g.UnixID)}) + // attrs = append(attrs, &ldap.EntryAttribute{Name: "memberUid", Values: h.getGroupMemberIDs(g.UnixID)}) + dn := fmt.Sprintf("cn=%s,%s", *g.Name, ls.groupDN) + entries = append(entries, &ldap.Entry{DN: dn, Attributes: attrs}) + } + case UserObjectClass, "": + users, err := ls.ac.Client.Core.CoreUsersList(core.NewCoreUsersListParams(), ls.ac.Auth) + if err != nil { + return ldap.ServerSearchResult{ResultCode: ldap.LDAPResultOperationsError}, fmt.Errorf("API Error: %s", err) + } + for _, u := range users.Payload.Results { + attrs := []*ldap.EntryAttribute{ + { + Name: "cn", + Values: []string{*u.Username}, + }, + { + Name: "uid", + Values: []string{strconv.Itoa(int(u.Pk))}, + }, + { + Name: "name", + Values: []string{*u.Name}, + }, + { + Name: "displayName", + Values: []string{*u.Name}, + }, + { + Name: "mail", + Values: []string{u.Email.String()}, + }, + { + Name: "objectClass", + Values: []string{UserObjectClass, "organizationalPerson", "goauthentik.io/ldap/user"}, + }, + } + + if u.IsActive { + attrs = append(attrs, &ldap.EntryAttribute{Name: "accountStatus", Values: []string{"inactive"}}) + } else { + attrs = append(attrs, &ldap.EntryAttribute{Name: "accountStatus", Values: []string{"active"}}) + } + + if *u.IsSuperuser { + attrs = append(attrs, &ldap.EntryAttribute{Name: "superuser", Values: []string{"inactive"}}) + } else { + attrs = append(attrs, &ldap.EntryAttribute{Name: "superuser", Values: []string{"active"}}) + } + + // attrs = append(attrs, &ldap.EntryAttribute{Name: "memberOf", Values: h.getGroupDNs(append(u.OtherGroups, u.PrimaryGroup))}) + + attrs = append(attrs, AKAttrsToLDAP(u.Attributes)...) + + dn := fmt.Sprintf("cn=%s,%s", *u.Name, ls.userDN) + entries = append(entries, &ldap.Entry{DN: dn, Attributes: attrs}) + } + } + ls.log.Debug(fmt.Sprintf("AP: Search OK: %s", searchReq.Filter)) + return ldap.ServerSearchResult{Entries: entries, Referrals: []string{}, Controls: []ldap.Control{}, ResultCode: ldap.LDAPResultSuccess}, nil +} diff --git a/outpost/pkg/ldap/utils.go b/outpost/pkg/ldap/utils.go new file mode 100644 index 000000000..35b454297 --- /dev/null +++ b/outpost/pkg/ldap/utils.go @@ -0,0 +1,20 @@ +package ldap + +import ( + "github.com/nmcclain/ldap" +) + +func AKAttrsToLDAP(attrs interface{}) []*ldap.EntryAttribute { + attrList := []*ldap.EntryAttribute{} + for attrKey, attrValue := range attrs.(map[string]interface{}) { + entry := &ldap.EntryAttribute{Name: attrKey} + switch attrValue.(type) { + case []string: + entry.Values = attrValue.([]string) + case string: + entry.Values = []string{attrValue.(string)} + } + attrList = append(attrList, entry) + } + return attrList +}