connector/ldap: expand LDAP connector to include searches

This commit is contained in:
Eric Chiang 2016-10-20 17:17:43 -07:00
parent 88896eb949
commit 13f7dfaef0
2 changed files with 401 additions and 15 deletions

View File

@ -2,58 +2,421 @@
package ldap
import (
"errors"
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net"
"strings"
"unicode"
"gopkg.in/ldap.v2"
"github.com/coreos/dex/connector"
)
// Config holds the configuration parameters for the LDAP connector.
// Config holds the configuration parameters for the LDAP 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.
//
// An example config:
//
// type: ldap
// config:
// host: ldap.example.com:636
// # The following field is required if using port 389.
// # insecureNoSSL: true
// rootCA: /etc/dex/ldap.ca
// bindDN: uid=seviceaccount,cn=users,dc=example,dc=com
// bindPW: password
// userSearch:
// # Would translate to the query "(&(objectClass=person)(uid=<username>))"
// baseDN: cn=users,dc=example,dc=com
// filter: "(objectClass=person)"
// username: uid
// idAttr: uid
// emailAttr: mail
// nameAttr: name
// groupSearch:
// # Would translate to the query "(&(objectClass=group)(member=<user uid>))"
// baseDN: cn=groups,dc=example,dc=com
// filter: "(objectClass=group)"
// userAttr: uid
// groupAttr: member
// nameAttr: name
//
type Config struct {
Host string `yaml:"host"`
// The host and optional port of the LDAP server. If port isn't supplied, it will be
// guessed based on the TLS configuration. 389 or 636.
Host string `yaml:"host"`
// Required if LDAP host does not use TLS.
InsecureNoSSL bool `yaml:"insecureNoSSL"`
// Path to a trusted root certificate file.
RootCA string `yaml:"rootCA"`
// BindDN and BindPW for an application service account. The connector uses these
// credentials to search for users and groups.
BindDN string `yaml:"bindDN"`
BindPW string `yaml:"bindPW"`
// User entry search configuration.
UserSearch struct {
// BsaeDN to start the search from. For example "cn=users,dc=example,dc=com"
BaseDN string `yaml:"baseDN"`
// Optional filter to apply when searching the directory. For example "(objectClass=person)"
Filter string `yaml:"filter"`
// Attribute to match against the inputted username. This will be translated and combined
// with the other filter as "(<attr>=<username>)".
Username string `yaml:"username"`
// Can either be:
// * "sub" - search the whole sub tree
// * "one" - only search one level
Scope string `yaml:"scope"`
// A mapping of attributes on the user entry to claims.
IDAttr string `yaml:"idAttr"` // Defaults to "uid"
EmailAttr string `yaml:"emailAttr"` // Defaults to "mail"
NameAttr string `yaml:"nameAttr"` // No default.
} `yaml:"userSearch"`
// Group search configuration.
GroupSearch struct {
// BsaeDN to start the search from. For example "cn=groups,dc=example,dc=com"
BaseDN string `yaml:"baseDN"`
// Optional filter to apply when searching the directory. For example "(objectClass=posixGroup)"
Filter string `yaml:"filter"`
Scope string `yaml:"scope"` // Defaults to "sub"
// These two fields are use to match a user to a group.
//
// It adds an additional requirement to the filter that an attribute in the group
// match the user's attribute value. For example that the "members" attribute of
// a group matches the "uid" of the user. The exact filter being added is:
//
// (<groupAttr>=<userAttr value>)
//
UserAttr string `yaml:"userAttr"`
GroupAttr string `yaml:"groupAttr"`
// The attribute of the group that represents its name.
NameAttr string `yaml:"nameAttr"`
} `yaml:"groupSearch"`
}
func parseScope(s string) (int, bool) {
// NOTE(ericchiang): ScopeBaseObject doesn't really make sense for us because we
// never know the user's or group's DN.
switch s {
case "", "sub":
return ldap.ScopeWholeSubtree, true
case "one":
return ldap.ScopeSingleLevel, true
}
return 0, false
}
// escapeRune maps a rune to a hex encoded value. For example 'é' would become '\\c3\\a9'
func escapeRune(buff *bytes.Buffer, r rune) {
// Really inefficient, but it seems correct.
for _, b := range []byte(string(r)) {
buff.WriteString("\\")
buff.WriteString(hex.EncodeToString([]byte{b}))
}
}
// NOTE(ericchiang): There are no good documents on how to escape an LDAP string.
// This implementation is inspired by an Oracle document, and is purposefully
// extremely restrictive.
//
// See: https://docs.oracle.com/cd/E19424-01/820-4811/gdxpo/index.html
func escapeFilter(s string) string {
r := strings.NewReader(s)
buff := new(bytes.Buffer)
for {
ru, _, err := r.ReadRune()
if err != nil {
// ignore decoding issues
return buff.String()
}
switch {
case ru > unicode.MaxASCII: // Not ASCII
escapeRune(buff, ru)
case !unicode.IsPrint(ru): // Not printable
escapeRune(buff, ru)
case strings.ContainsRune(`*\()`, ru): // Reserved characters
escapeRune(buff, ru)
default:
buff.WriteRune(ru)
}
}
}
// Open returns an authentication strategy using LDAP.
func (c *Config) Open() (connector.Connector, error) {
if c.Host == "" {
return nil, errors.New("missing host parameter")
requiredFields := []struct {
name string
val string
}{
{"host", c.Host},
{"userSearch.baseDN", c.UserSearch.BaseDN},
{"userSearch.username", c.UserSearch.Username},
}
if c.BindDN == "" {
return nil, errors.New("missing bindDN paramater")
for _, field := range requiredFields {
if field.val == "" {
return nil, fmt.Errorf("ldap: missing required field %q", field.name)
}
}
return &ldapConnector{*c}, nil
var (
host string
err error
)
if host, _, err = net.SplitHostPort(c.Host); err != nil {
host = c.Host
if c.InsecureNoSSL {
c.Host = c.Host + ":389"
} else {
c.Host = c.Host + ":636"
}
}
tlsConfig := new(tls.Config)
if c.RootCA != "" {
data, err := ioutil.ReadFile(c.RootCA)
if err != nil {
return nil, fmt.Errorf("ldap: read ca file: %v", err)
}
rootCAs := x509.NewCertPool()
if !rootCAs.AppendCertsFromPEM(data) {
return nil, fmt.Errorf("ldap: no certs found in ca file")
}
tlsConfig.RootCAs = rootCAs
// NOTE(ericchiang): This was required for our internal LDAP server
// but might be because of an issue with our root CA.
tlsConfig.ServerName = host
}
userSearchScope, ok := parseScope(c.UserSearch.Scope)
if !ok {
return nil, fmt.Errorf("userSearch.Scope unknown value %q", c.UserSearch.Scope)
}
groupSearchScope, ok := parseScope(c.GroupSearch.Scope)
if !ok {
return nil, fmt.Errorf("userSearch.Scope unknown value %q", c.GroupSearch.Scope)
}
return &ldapConnector{*c, userSearchScope, groupSearchScope, tlsConfig}, nil
}
type ldapConnector struct {
Config
userSearchScope int
groupSearchScope int
tlsConfig *tls.Config
}
var _ connector.PasswordConnector = (*ldapConnector)(nil)
// do initializes a connection to the LDAP directory and passes it to the
// provided function. It then performs appropriate teardown or reuse before
// returning.
func (c *ldapConnector) do(f func(c *ldap.Conn) error) error {
// TODO(ericchiang): Connection pooling.
conn, err := ldap.Dial("tcp", c.Host)
var (
conn *ldap.Conn
err error
)
if c.InsecureNoSSL {
conn, err = ldap.Dial("tcp", c.Host)
} else {
conn, err = ldap.DialTLS("tcp", c.Host, c.tlsConfig)
}
if err != nil {
return fmt.Errorf("failed to connect: %v", err)
}
defer conn.Close()
// If bindDN and bindPW are empty this will default to an anonymous bind.
if err := conn.Bind(c.BindDN, c.BindPW); err != nil {
return fmt.Errorf("ldap: initial bind for user %q failed: %v", c.BindDN, err)
}
return f(conn)
}
func (c *ldapConnector) Login(username, password string) (connector.Identity, bool, error) {
err := c.do(func(conn *ldap.Conn) error {
return conn.Bind(fmt.Sprintf("uid=%s,%s", username, c.BindDN), password)
func getAttr(e ldap.Entry, name string) string {
for _, a := range e.Attributes {
if a.Name != name {
continue
}
if len(a.Values) == 0 {
return ""
}
return a.Values[0]
}
return ""
}
func (c *ldapConnector) Login(username, password string) (ident connector.Identity, validPass bool, err error) {
var (
// We want to return a different error if the user's password is incorrect vs
// if there was an error.
incorrectPass = false
user ldap.Entry
)
filter := fmt.Sprintf("(%s=%s)", c.UserSearch.Username, escapeFilter(username))
if c.UserSearch.Filter != "" {
filter = fmt.Sprintf("(&%s%s)", c.UserSearch.Filter, filter)
}
// Initial search.
req := &ldap.SearchRequest{
BaseDN: c.UserSearch.BaseDN,
Filter: filter,
Scope: c.userSearchScope,
// We only need to search for these specific requests.
Attributes: []string{
c.UserSearch.IDAttr,
c.UserSearch.EmailAttr,
c.GroupSearch.UserAttr,
// TODO(ericchiang): what if this contains duplicate values?
},
}
if c.UserSearch.NameAttr != "" {
req.Attributes = append(req.Attributes, c.UserSearch.NameAttr)
}
err = c.do(func(conn *ldap.Conn) error {
resp, err := conn.Search(req)
if err != nil {
return fmt.Errorf("ldap: search with filter %q failed: %v", req.Filter, err)
}
switch n := len(resp.Entries); n {
case 0:
return fmt.Errorf("ldap: no results returned for filter: %q", filter)
case 2:
return fmt.Errorf("ldap: filter returned multiple (%d) results: %q", n, filter)
}
user = *resp.Entries[0]
// Try to authenticate as the distinguished name.
if err := conn.Bind(user.DN, password); err != nil {
// Detect a bad password through the LDAP error code.
if ldapErr, ok := err.(*ldap.Error); ok {
if ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials {
log.Printf("ldap: invalid password for user %q", user.DN)
incorrectPass = true
return nil
}
}
return fmt.Errorf("ldap: failed to bind as dn %q: %v", user.DN, err)
}
return nil
})
if err != nil {
// TODO(ericchiang): Determine when the user has entered invalid credentials.
return connector.Identity{}, false, err
}
return connector.Identity{Username: username}, true, nil
// Encode entry for follow up requests such as the groups query and
// refresh attempts.
if ident.ConnectorData, err = json.Marshal(user); err != nil {
return connector.Identity{}, false, fmt.Errorf("ldap: marshal entry: %v", err)
}
// If we're missing any attributes, such as email or ID, we want to report
// an error rather than continuing.
missing := []string{}
// Fill the identity struct using the attributes from the user entry.
if ident.UserID = getAttr(user, c.UserSearch.IDAttr); ident.UserID == "" {
missing = append(missing, c.UserSearch.IDAttr)
}
if ident.Email = getAttr(user, c.UserSearch.EmailAttr); ident.Email == "" {
missing = append(missing, c.UserSearch.EmailAttr)
}
if c.UserSearch.NameAttr != "" {
if ident.Username = getAttr(user, c.UserSearch.NameAttr); ident.Username == "" {
missing = append(missing, c.UserSearch.NameAttr)
}
}
if len(missing) != 0 {
err := fmt.Errorf("ldap: entry %q missing following required attribute(s): %q", user.DN, missing)
return connector.Identity{}, false, err
}
return ident, !incorrectPass, nil
}
func (c *ldapConnector) Groups(ident connector.Identity) ([]string, error) {
// Decode the user entry from the identity.
var user ldap.Entry
if err := json.Unmarshal(ident.ConnectorData, &user); err != nil {
return nil, fmt.Errorf("ldap: failed to unmarshal connector data: %v", err)
}
filter := fmt.Sprintf("(%s=%s)", c.GroupSearch.GroupAttr, escapeFilter(getAttr(user, c.GroupSearch.UserAttr)))
if c.GroupSearch.Filter != "" {
filter = fmt.Sprintf("(&%s%s)", c.GroupSearch.Filter, filter)
}
req := &ldap.SearchRequest{
BaseDN: c.GroupSearch.BaseDN,
Filter: filter,
Scope: c.groupSearchScope,
Attributes: []string{c.GroupSearch.NameAttr},
}
var groups []*ldap.Entry
if err := c.do(func(conn *ldap.Conn) error {
resp, err := conn.Search(req)
if err != nil {
return fmt.Errorf("ldap: search failed: %v", err)
}
groups = resp.Entries
return nil
}); err != nil {
return nil, err
}
if len(groups) == 0 {
// TODO(ericchiang): Is this going to spam the logs?
log.Printf("ldap: groups search with filter %q returned no groups", filter)
}
var groupNames []string
for _, group := range groups {
name := getAttr(*group, c.GroupSearch.NameAttr)
if name == "" {
// Be obnoxious about missing missing attributes. If the group entry is
// missing its name attribute, that indicates a misconfiguration.
//
// In the future we can add configuration options to just log these errors.
return nil, fmt.Errorf("ldap: group entity %q missing required attribute %q",
group.DN, c.GroupSearch.NameAttr)
}
groupNames = append(groupNames, name)
}
return groupNames, nil
}
func (c *ldapConnector) Close() error {

View File

@ -0,0 +1,23 @@
package ldap
import "testing"
func TestEscapeFilter(t *testing.T) {
tests := []struct {
val string
want string
}{
{"Five*Star", "Five\\2aStar"},
{"c:\\File", "c:\\5cFile"},
{"John (2nd)", "John \\282nd\\29"},
{string([]byte{0, 0, 0, 4}), "\\00\\00\\00\\04"},
{"Chloé", "Chlo\\c3\\a9"},
}
for _, tc := range tests {
got := escapeFilter(tc.val)
if tc.want != got {
t.Errorf("value %q want=%q, got=%q", tc.val, tc.want, got)
}
}
}