diff --git a/connector/ldapcluster/ldapcluster.go b/connector/ldapcluster/ldapcluster.go new file mode 100644 index 00000000..b1b0058c --- /dev/null +++ b/connector/ldapcluster/ldapcluster.go @@ -0,0 +1,218 @@ +// Package ldapcluster implements strategies for authenticating with a cluster of LDAP servers using the LDAP protocol. +package ldapcluster + +import ( + "context" + + "github.com/dexidp/dex/connector" + conn_ldap "github.com/dexidp/dex/connector/ldap" + "github.com/dexidp/dex/pkg/log" +) + +// Config holds the configuration parameters for the LDAP cluster connector. The LDAP +// connectors require executing two queries, the first to find the user based on +// the username and password given to the connector. The second to use the user +// entry to search for groups. +// The cluster connector takes multiple LDAP connectors. +// +// An example config: +//connectors: +//- type: ldapcluster +// name: OpenLDAP +// id: ldapcluster +// config: +// clustermembers: +// - host: localhost:399 +// +// # No TLS for this setup. +// insecureNoSSL: true +// +// # This would normally be a read-only user. +// bindDN: cn=admin,dc=example,dc=org +// bindPW: admin +// +// usernamePrompt: Email Address +// +// userSearch: +// baseDN: ou=People,dc=example,dc=org +// filter: "(objectClass=person)" +// username: mail +// # "DN" (case sensitive) is a special attribute name. It indicates that +// # this value should be taken from the entity's DN not an attribute on +// # the entity. +// idAttr: DN +// emailAttr: mail +// nameAttr: cn +// +// groupSearch: +// baseDN: ou=Groups,dc=example,dc=org +// filter: "(objectClass=groupOfNames)" +// +// userMatchers: +// # A user is a member of a group when their DN matches +// # the value of a "member" attribute on the group entity. +// - userAttr: DN +// groupAttr: member +// +// # The group name should be the "cn" value. +// nameAttr: cn +// +// - host: localhost:389 +// +// # No TLS for this setup. +// insecureNoSSL: true +// +// # This would normally be a read-only user. +// bindDN: cn=admin,dc=example,dc=org +// bindPW: admin +// +// usernamePrompt: Email Address +// +// userSearch: +// baseDN: ou=People,dc=example,dc=org +// filter: "(objectClass=person)" +// username: mail +// # "DN" (case sensitive) is a special attribute name. It indicates that +// # this value should be taken from the entity's DN not an attribute on +// # the entity. +// idAttr: DN +// emailAttr: mail +// nameAttr: cn +// +// groupSearch: +// baseDN: ou=Groups,dc=example,dc=org +// filter: "(objectClass=groupOfNames)" +// +// userMatchers: +// # A user is a member of a group when their DN matches +// # the value of a "member" attribute on the group entity. +// - userAttr: DN +// groupAttr: member +// +// # The group name should be the "cn" value. +// nameAttr: cn +// + +type Config struct { + ClusterMembers []conn_ldap.Config +} + +// Open returns an authentication strategy using LDAP. +func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) { + conn, err := c.OpenConnector(logger) + if err != nil { + return nil, err + } + return connector.Connector(conn), nil +} + +// OpenConnector is the same as Open but returns a type with all implemented connector interfaces. +func (c *Config) OpenConnector(logger log.Logger) (interface { + connector.Connector + connector.PasswordConnector + connector.RefreshConnector +}, error) { + return c.openConnector(logger) +} + +func (c *Config) openConnector(logger log.Logger) (*ldapClusterConnector, error) { + var lcc ldapClusterConnector + // Initialize each of the connector members. + for _, v := range c.ClusterMembers { + lc, e := v.OpenConnector(logger) + if e != nil { + return nil, e + } + lcc.MemberConnectors = append(lcc.MemberConnectors, lc) + } + + lcc.activeMemberIdx = 0 + lcc.logger = logger + + return &lcc, nil +} + +type ConnectorIf interface { + connector.Connector + connector.PasswordConnector + connector.RefreshConnector +} + +type ldapClusterConnector struct { + MemberConnectors [](ConnectorIf) + activeMemberIdx int + logger log.Logger +} + +func (c *ldapClusterConnector) Login(ctx context.Context, s connector.Scopes, username, password string) (ident connector.Identity, validPass bool, err error) { + // make this check to avoid unauthenticated bind to the LDAP server. + if password == "" { + return connector.Identity{}, false, nil + } + + // Check the active connector first. + // If the active connector index is -1, we will start + // with first connector. + if c.activeMemberIdx == -1 { + c.activeMemberIdx = 0 + } + lc := c.MemberConnectors[c.activeMemberIdx] + i, b, e := lc.Login(ctx, s, username, password) + if e != nil { + c.logger.Infof("Failed to connect to server idx: %d", c.activeMemberIdx) + // Current active server has returned error. + // Try the other servers in round robin manner. + // If the error returned by a server is nil, + // then make that server as + // the current active server. + for k, v := range c.MemberConnectors { + if k == c.activeMemberIdx { + // we just tried it. + // hence skip. + continue + } + i, b, e = v.Login(ctx, s, username, password) + if e == nil { + c.logger.Infof("setting active index as: %d", k) + c.activeMemberIdx = k + return i, b, e + } + } + } + return i, b, e +} + +func (c *ldapClusterConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) { + lc := c.MemberConnectors[c.activeMemberIdx] + i, e := lc.Refresh(ctx, s, ident) + if e != nil { + c.logger.Infof("Failed to connect to active index: %d", c.activeMemberIdx) + // current active server has returned error. + // Try the other servers in round robin manner. + // If the error returned by a server is nil, + // then make that server as + // the current active server. + for k, v := range c.MemberConnectors { + if k == c.activeMemberIdx { + // we just tried it. + // hence skip. + continue + } + c.logger.Infof("Trying index: %d", k) + i, e = v.Refresh(ctx, s, ident) + if e == nil { + c.logger.Infof("setting active index as: %d", k) + c.activeMemberIdx = k + return i, nil + } + c.logger.Errorf("Failed to connect to index: %d", k) + } + } + + return i, e +} + +func (c *ldapClusterConnector) Prompt() string { + lc := c.MemberConnectors[c.activeMemberIdx] + return lc.Prompt() +} diff --git a/connector/ldapcluster/ldapcluster_test.go b/connector/ldapcluster/ldapcluster_test.go new file mode 100644 index 00000000..51a3ffbc --- /dev/null +++ b/connector/ldapcluster/ldapcluster_test.go @@ -0,0 +1,125 @@ +package ldapcluster + +import ( + "context" + "errors" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + "github.com/dexidp/dex/connector" +) + +type MockConn struct { + status bool +} + +func (c MockConn) Login(ctx context.Context, s connector.Scopes, username, password string) (ident connector.Identity, validPass bool, err error) { + if c.status { + return connector.Identity{}, false, nil + } + return connector.Identity{}, false, errors.New("failed") +} + +func (c MockConn) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) { + if c.status { + return connector.Identity{}, nil + } + return connector.Identity{}, errors.New("failed") +} + +func (c MockConn) Prompt() string { + return "name:" +} + +func TestLoginSingle(t *testing.T) { + var ctx context.Context + var s connector.Scopes + + var c1 MockConn + c1.status = true + + var lcc ldapClusterConnector + lcc.MemberConnectors = append(lcc.MemberConnectors, c1) + lcc.activeMemberIdx = 0 + + var logger *log.Logger = log.New() + lcc.logger = logger + _, _, e := lcc.Login(ctx, s, "testuser", "password") + assert.Equal(t, e, nil) +} + +func TestLoginMultiple(t *testing.T) { + var ctx context.Context + var s connector.Scopes + + var c1 MockConn + c1.status = false + + var c2 MockConn + c2.status = true + + var lcc ldapClusterConnector + lcc.MemberConnectors = append(lcc.MemberConnectors, c1) + lcc.activeMemberIdx = 0 + lcc.MemberConnectors = append(lcc.MemberConnectors, c2) + + var logger *log.Logger = log.New() + lcc.logger = logger + _, _, e := lcc.Login(ctx, s, "testuser", "password") + assert.Equal(t, e, nil) + assert.Equal(t, lcc.activeMemberIdx, 1) +} + +func TestLoginMultiple2(t *testing.T) { + var ctx context.Context + var s connector.Scopes + + var c1 MockConn + c1.status = false + + var c2 MockConn + c2.status = false + + var c3 MockConn + c3.status = true + + var lcc ldapClusterConnector + lcc.MemberConnectors = append(lcc.MemberConnectors, c1) + lcc.activeMemberIdx = 0 + lcc.MemberConnectors = append(lcc.MemberConnectors, c2) + lcc.MemberConnectors = append(lcc.MemberConnectors, c3) + + var logger *log.Logger = log.New() + lcc.logger = logger + _, _, e := lcc.Login(ctx, s, "testuser", "password") + assert.Equal(t, e, nil) + assert.Equal(t, lcc.activeMemberIdx, 2) +} + +func TestRefreshMultiple(t *testing.T) { + var ctx context.Context + var s connector.Scopes + + var c1 MockConn + c1.status = true + + var c2 MockConn + c2.status = false + + var c3 MockConn + c3.status = false + + var lcc ldapClusterConnector + lcc.MemberConnectors = append(lcc.MemberConnectors, c1) + lcc.activeMemberIdx = 1 + lcc.MemberConnectors = append(lcc.MemberConnectors, c2) + lcc.MemberConnectors = append(lcc.MemberConnectors, c3) + + var logger *log.Logger = log.New() + lcc.logger = logger + _, e := lcc.Refresh(ctx, s, connector.Identity{}) + assert.Equal(t, e, nil) + assert.Equal(t, lcc.activeMemberIdx, 0) +} diff --git a/examples/ldapcluster/config-ldapcluster.yaml b/examples/ldapcluster/config-ldapcluster.yaml new file mode 100644 index 00000000..8b8ada05 --- /dev/null +++ b/examples/ldapcluster/config-ldapcluster.yaml @@ -0,0 +1,90 @@ +issuer: http://127.0.0.1:5556/dex +storage: + type: sqlite3 + config: + file: examples/dex.db +web: + http: 0.0.0.0:5556 + +connectors: +- type: ldapcluster + name: OpenLDAP + id: ldapcluster + config: + clustermembers: + - host: localhost:399 + + # No TLS for this setup. + insecureNoSSL: true + + # This would normally be a read-only user. + bindDN: cn=admin,dc=example,dc=org + bindPW: admin + + usernamePrompt: Email Address + + userSearch: + baseDN: ou=People,dc=example,dc=org + filter: "(objectClass=person)" + username: mail + # "DN" (case sensitive) is a special attribute name. It indicates that + # this value should be taken from the entity's DN not an attribute on + # the entity. + idAttr: DN + emailAttr: mail + nameAttr: cn + + groupSearch: + baseDN: ou=Groups,dc=example,dc=org + filter: "(objectClass=groupOfNames)" + + userMatchers: + # A user is a member of a group when their DN matches + # the value of a "member" attribute on the group entity. + - userAttr: DN + groupAttr: member + + # The group name should be the "cn" value. + nameAttr: cn + + - host: localhost:389 + + # No TLS for this setup. + insecureNoSSL: true + + # This would normally be a read-only user. + bindDN: cn=admin,dc=example,dc=org + bindPW: admin + + usernamePrompt: Email Address + + userSearch: + baseDN: ou=People,dc=example,dc=org + filter: "(objectClass=person)" + username: mail + # "DN" (case sensitive) is a special attribute name. It indicates that + # this value should be taken from the entity's DN not an attribute on + # the entity. + idAttr: DN + emailAttr: mail + nameAttr: cn + + groupSearch: + baseDN: ou=Groups,dc=example,dc=org + filter: "(objectClass=groupOfNames)" + + userMatchers: + # A user is a member of a group when their DN matches + # the value of a "member" attribute on the group entity. + - userAttr: DN + groupAttr: member + + # The group name should be the "cn" value. + nameAttr: cn + +staticClients: +- id: example-app + redirectURIs: + - 'http://127.0.0.1:5555/callback' + name: 'Example App' + secret: ZXhhbXBsZS1hcHAtc2VjcmV0 diff --git a/server/server.go b/server/server.go index c4f16536..3991e9eb 100644 --- a/server/server.go +++ b/server/server.go @@ -38,6 +38,7 @@ import ( "github.com/dexidp/dex/connector/google" "github.com/dexidp/dex/connector/keystone" "github.com/dexidp/dex/connector/ldap" + "github.com/dexidp/dex/connector/ldapcluster" "github.com/dexidp/dex/connector/linkedin" "github.com/dexidp/dex/connector/microsoft" "github.com/dexidp/dex/connector/mock" @@ -688,6 +689,7 @@ var ConnectorsConfig = map[string]func() ConnectorConfig{ "mockCallback": func() ConnectorConfig { return new(mock.CallbackConfig) }, "mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) }, "ldap": func() ConnectorConfig { return new(ldap.Config) }, + "ldapcluster": func() ConnectorConfig { return new(ldapcluster.Config) }, "gitea": func() ConnectorConfig { return new(gitea.Config) }, "github": func() ConnectorConfig { return new(github.Config) }, "gitlab": func() ConnectorConfig { return new(gitlab.Config) },