dex/connector/connector_ldap.go

571 lines
15 KiB
Go
Raw Normal View History

package connector
import (
"crypto/tls"
"crypto/x509"
"errors"
"net"
"fmt"
"html/template"
"io/ioutil"
"net/http"
"net/url"
"path"
"strings"
"sync"
"time"
"github.com/coreos/dex/pkg/log"
"github.com/coreos/go-oidc/oidc"
"gopkg.in/ldap.v2"
)
const (
LDAPConnectorType = "ldap"
LDAPLoginPageTemplateName = "ldap-login.html"
)
func init() {
RegisterConnectorConfigType(LDAPConnectorType, func() ConnectorConfig { return &LDAPConnectorConfig{} })
// Set default ldap timeout.
ldap.DefaultTimeout = 30 * time.Second
}
type LDAPConnectorConfig struct {
ID string `json:"id"`
// Host and port of ldap service in form "host:port"
Host string `json:"host"`
// UseTLS indicates that the connector should use the TLS port.
UseTLS bool `json:"useTLS"`
UseSSL bool `json:"useSSL"`
// Trusted TLS certificate when connecting to the LDAP server. If empty the
// host's root certificates will be used.
CaFile string `json:"caFile"`
// CertFile and KeyFile are used to specifiy client certificate data.
CertFile string `json:"certFile"`
KeyFile string `json:"keyFile"`
MaxIdleConn int `json:"maxIdleConn"`
NameAttribute string `json:"nameAttribute"`
EmailAttribute string `json:"emailAttribute"`
// The place to start all searches from.
BaseDN string `json:"baseDN"`
// Search fields indicate how to search for user records in LDAP.
SearchBeforeAuth bool `json:"searchBeforeAuth"`
SearchFilter string `json:"searchFilter"`
SearchScope string `json:"searchScope"`
SearchBindDN string `json:"searchBindDN"`
SearchBindPw string `json:"searchBindPw"`
SearchGroupFilter string `json:"searchGroupFilter"`
// BindTemplate is a format string that maps user names to a record to bind as.
// It's passed both the username entered by the end user and the base DN.
//
// For example the bindTemplate
//
// "uid=%u,%d"
//
// with the username "johndoe" and basename "ou=People,dc=example,dc=com" would attempt
// to bind as
//
// "uid=johndoe,ou=People,dc=example,dc=com"
//
BindTemplate string `json:"bindTemplate"`
// DEPRICATED fields that exist for backward compatibility.
// Use "host" instead of "ServerHost" and "ServerPort"
ServerHost string `json:"serverHost"`
ServerPort uint16 `json:"serverPort"`
Timeout time.Duration `json:"timeout"`
}
func (cfg *LDAPConnectorConfig) ConnectorID() string {
return cfg.ID
}
func (cfg *LDAPConnectorConfig) ConnectorType() string {
return LDAPConnectorType
}
type LDAPConnector struct {
id string
namespace url.URL
loginFunc oidc.LoginFunc
loginTpl *template.Template
baseDN string
nameAttribute string
emailAttribute string
searchBeforeAuth bool
searchFilter string
searchScope int
searchBindDN string
searchBindPw string
searchGroupFilter string
bindTemplate string
ldapPool *LDAPPool
}
const defaultPoolCheckTimer = 7200 * time.Second
func (cfg *LDAPConnectorConfig) Connector(ns url.URL, lf oidc.LoginFunc, tpls *template.Template) (Connector, error) {
ns.Path = path.Join(ns.Path, httpPathCallback)
tpl := tpls.Lookup(LDAPLoginPageTemplateName)
if tpl == nil {
return nil, fmt.Errorf("unable to find necessary HTML template")
}
if cfg.UseTLS && cfg.UseSSL {
return nil, fmt.Errorf("Invalid configuration. useTLS and useSSL are mutual exclusive.")
}
if len(cfg.CertFile) > 0 && len(cfg.KeyFile) == 0 {
return nil, fmt.Errorf("Invalid configuration. Both certFile and keyFile must be specified.")
}
// Set default values
if cfg.NameAttribute == "" {
cfg.NameAttribute = "cn"
}
if cfg.EmailAttribute == "" {
cfg.EmailAttribute = "mail"
}
if cfg.MaxIdleConn > 0 {
cfg.MaxIdleConn = 5
}
if cfg.BindTemplate == "" {
cfg.BindTemplate = "uid=%u,%b"
} else if cfg.SearchBeforeAuth {
log.Warningf("bindTemplate not used when searchBeforeAuth specified.")
}
searchScope := ldap.ScopeWholeSubtree
if cfg.SearchScope != "" {
switch {
case strings.EqualFold(cfg.SearchScope, "BASE"):
searchScope = ldap.ScopeBaseObject
case strings.EqualFold(cfg.SearchScope, "ONE"):
searchScope = ldap.ScopeSingleLevel
case strings.EqualFold(cfg.SearchScope, "SUB"):
searchScope = ldap.ScopeWholeSubtree
default:
return nil, fmt.Errorf("Invalid value for searchScope: '%v'. Must be one of 'base', 'one' or 'sub'.", cfg.SearchScope)
}
}
if cfg.Host == "" {
if cfg.ServerHost == "" {
return nil, errors.New("no host provided")
}
// For backward compatibility construct host form old fields.
cfg.Host = fmt.Sprintf("%s:%d", cfg.ServerHost, cfg.ServerPort)
}
host, _, err := net.SplitHostPort(cfg.Host)
if err != nil {
return nil, fmt.Errorf("host is not of form 'host:port': %v", err)
}
tlsConfig := &tls.Config{ServerName: host}
if (cfg.UseTLS || cfg.UseSSL) && len(cfg.CaFile) > 0 {
buf, err := ioutil.ReadFile(cfg.CaFile)
if err != nil {
return nil, err
}
rootCertPool := x509.NewCertPool()
ok := rootCertPool.AppendCertsFromPEM(buf)
if ok {
tlsConfig.RootCAs = rootCertPool
} else {
return nil, fmt.Errorf("%v: Unable to parse certificate data.", cfg.CaFile)
}
}
if (cfg.UseTLS || cfg.UseSSL) && len(cfg.CertFile) > 0 && len(cfg.KeyFile) > 0 {
cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile)
if err != nil {
return nil, err
}
tlsConfig.Certificates = []tls.Certificate{cert}
}
idpc := &LDAPConnector{
id: cfg.ID,
namespace: ns,
loginFunc: lf,
loginTpl: tpl,
baseDN: cfg.BaseDN,
nameAttribute: cfg.NameAttribute,
emailAttribute: cfg.EmailAttribute,
searchBeforeAuth: cfg.SearchBeforeAuth,
searchFilter: cfg.SearchFilter,
searchGroupFilter: cfg.SearchGroupFilter,
searchScope: searchScope,
searchBindDN: cfg.SearchBindDN,
searchBindPw: cfg.SearchBindPw,
bindTemplate: cfg.BindTemplate,
ldapPool: &LDAPPool{
MaxIdleConn: cfg.MaxIdleConn,
PoolCheckTimer: defaultPoolCheckTimer,
Host: cfg.Host,
UseTLS: cfg.UseTLS,
UseSSL: cfg.UseSSL,
TLSConfig: tlsConfig,
},
}
return idpc, nil
}
func (c *LDAPConnector) ID() string {
return c.id
}
func (c *LDAPConnector) Healthy() error {
return c.ldapPool.Do(func(c *ldap.Conn) error {
// Attempt an anonymous bind.
return c.Bind("", "")
})
}
func (c *LDAPConnector) LoginURL(sessionKey, prompt string) (string, error) {
q := url.Values{}
q.Set("session_key", sessionKey)
q.Set("prompt", prompt)
enc := q.Encode()
return path.Join(c.namespace.Path, "login") + "?" + enc, nil
}
func (c *LDAPConnector) Handler(errorURL url.URL) http.Handler {
route := path.Join(c.namespace.Path, "/login")
return handlePasswordLogin(c.loginFunc, c.loginTpl, c, route, errorURL)
}
func (c *LDAPConnector) Sync() chan struct{} {
stop := make(chan struct{})
go func() {
for {
select {
case <-time.After(c.ldapPool.PoolCheckTimer):
alive, killed := c.ldapPool.CheckConnections()
if alive > 0 {
log.Infof("Connector ID=%v idle_conns=%v", c.id, alive)
}
if killed > 0 {
log.Warningf("Connector ID=%v closed %v dead connections.", c.id, killed)
}
case <-stop:
return
}
}
}()
return stop
}
func (c *LDAPConnector) TrustedEmailProvider() bool {
return true
}
// A LDAPPool is a Connection Pool for LDAP connections. Use Do() to request connections
// from the pool.
type LDAPPool struct {
m sync.Mutex
conns map[*ldap.Conn]struct{}
MaxIdleConn int
PoolCheckTimer time.Duration
Host string
UseTLS bool
UseSSL bool
TLSConfig *tls.Config
}
// Do runs a function which requires an LDAP connection.
//
// The connection will be unauthenticated with the server and should not be closed by f.
func (p *LDAPPool) Do(f func(conn *ldap.Conn) error) (err error) {
conn := p.removeRandomConn()
if conn == nil {
conn, err = p.ldapConnect()
if err != nil {
return err
}
}
defer p.put(conn)
return f(conn)
}
// put makes a connection ready for re-use and puts it back into the pool. If the connection
// cannot be reused it is discarded. If there already are MaxIdleConn connections in the pool
// the connection is discarded.
func (p *LDAPPool) put(c *ldap.Conn) {
p.m.Lock()
if p.conns == nil {
// First call to Put, initialize map
p.conns = make(map[*ldap.Conn]struct{})
}
if len(p.conns)+1 > p.MaxIdleConn {
p.m.Unlock()
c.Close()
return
}
p.m.Unlock()
// drop to anonymous bind
err := c.Bind("", "")
if err != nil {
// unsupported or disallowed, throw away connection
log.Warningf("Unable to re-use LDAP Connection after failure to bind anonymously: %v", err)
c.Close()
return
}
p.m.Lock()
p.conns[c] = struct{}{}
p.m.Unlock()
}
// removeConn attempts to remove the provided connection from the pool. If removeConn returns false
// another routine is using the connection and the caller should discard the pointer.
func (p *LDAPPool) removeConn(conn *ldap.Conn) bool {
p.m.Lock()
_, ok := p.conns[conn]
delete(p.conns, conn)
p.m.Unlock()
return ok
}
// removeRandomConn attempts to remove a random connection from the pool. If removeRandomConn
// returns nil the pool is empty.
func (p *LDAPPool) removeRandomConn() *ldap.Conn {
p.m.Lock()
defer p.m.Unlock()
for conn := range p.conns {
delete(p.conns, conn)
return conn
}
return nil
}
// CheckConnections attempts to iterate over all the connections in the pool and check wheter
// they are alive or not. Live connections are put back into the pool, dead ones are discarded.
func (p *LDAPPool) CheckConnections() (int, int) {
var conns []*ldap.Conn
var alive, killed int
// Get snapshot of connection-map while holding Lock
p.m.Lock()
for conn := range p.conns {
conns = append(conns, conn)
}
p.m.Unlock()
// Iterate over snapshot, Get and ping connections.
// Put live connections back into pool, Close dead ones.
for _, conn := range conns {
ok := p.removeConn(conn)
if ok {
err := ldapPing(conn)
if err == nil {
p.put(conn)
alive++
} else {
conn.Close()
killed++
}
}
}
return alive, killed
}
func ldapPing(conn *ldap.Conn) error {
// Query root DSE
s := ldap.NewSearchRequest("", ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false, "(objectClass=*)", []string{}, nil)
_, err := conn.Search(s)
return err
}
func (p *LDAPPool) ldapConnect() (*ldap.Conn, error) {
var err error
var ldapConn *ldap.Conn
if p.UseSSL {
ldapConn, err = ldap.DialTLS("tcp", p.Host, p.TLSConfig)
if err != nil {
return nil, err
}
} else {
ldapConn, err = ldap.Dial("tcp", p.Host)
if err != nil {
return nil, err
}
if p.UseTLS {
err = ldapConn.StartTLS(p.TLSConfig)
if err != nil {
return nil, err
}
}
}
return ldapConn, err
}
// invalidBindCredentials determines if a bind error was the result of invalid
// credentials.
func invalidBindCredentials(err error) bool {
ldapErr, ok := err.(*ldap.Error)
if ok {
return false
}
return ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials
}
func (c *LDAPConnector) formatDN(template, username string) string {
result := template
result = strings.Replace(result, "%u", ldap.EscapeFilter(username), -1)
result = strings.Replace(result, "%b", c.baseDN, -1)
return result
}
func (c *LDAPConnector) Groups(fullUserID string) ([]string, error) {
if !c.searchBeforeAuth {
return nil, fmt.Errorf("cannot search without service account")
}
if c.searchGroupFilter == "" {
return nil, fmt.Errorf("no group filter specified")
}
var groups []string
err := c.ldapPool.Do(func(conn *ldap.Conn) error {
if err := conn.Bind(c.searchBindDN, c.searchBindPw); err != nil {
if !invalidBindCredentials(err) {
log.Errorf("failed to connect to LDAP for search bind: %v", err)
}
return fmt.Errorf("failed to bind: %v", err)
}
req := &ldap.SearchRequest{
BaseDN: c.baseDN,
Scope: c.searchScope,
Filter: c.formatDN(c.searchGroupFilter, fullUserID),
}
resp, err := conn.Search(req)
if err != nil {
return fmt.Errorf("search failed: %v", err)
}
groups = make([]string, len(resp.Entries))
for i, entry := range resp.Entries {
groups[i] = entry.DN
}
return nil
})
return groups, err
}
func (c *LDAPConnector) Identity(username, password string) (*oidc.Identity, error) {
var (
identity *oidc.Identity
err error
)
if c.searchBeforeAuth {
err = c.ldapPool.Do(func(conn *ldap.Conn) error {
if err := conn.Bind(c.searchBindDN, c.searchBindPw); err != nil {
if !invalidBindCredentials(err) {
log.Errorf("failed to connect to LDAP for search bind: %v", err)
}
return fmt.Errorf("failed to bind: %v", err)
}
filter := c.formatDN(c.searchFilter, username)
req := &ldap.SearchRequest{
BaseDN: c.baseDN,
Scope: c.searchScope,
Filter: filter,
Attributes: []string{c.nameAttribute, c.emailAttribute},
}
resp, err := conn.Search(req)
if err != nil {
return fmt.Errorf("search failed: %v", err)
}
switch len(resp.Entries) {
case 0:
return errors.New("user not found by search")
case 1:
default:
// For now reject searches that return multiple entries to avoid ambiguity.
log.Errorf("LDAP search %q returned %d entries. Must disambiguate searchFilter.", filter, len(resp.Entries))
return errors.New("search returned multiple entries")
}
entry := resp.Entries[0]
email := entry.GetAttributeValue(c.emailAttribute)
if email == "" {
return fmt.Errorf("no email attribute found")
}
identity = &oidc.Identity{
ID: entry.DN,
Name: entry.GetAttributeValue(c.nameAttribute),
Email: email,
}
// Attempt to bind as the end user.
return conn.Bind(entry.DN, password)
})
} else {
err = c.ldapPool.Do(func(conn *ldap.Conn) error {
userBindDN := c.formatDN(c.bindTemplate, username)
if err := conn.Bind(userBindDN, password); err != nil {
if !invalidBindCredentials(err) {
log.Errorf("failed to connect to LDAP for search bind: %v", err)
}
return fmt.Errorf("failed to bind: %v", err)
}
req := &ldap.SearchRequest{
BaseDN: userBindDN,
Scope: ldap.ScopeBaseObject, // Only attempt to
Filter: "(objectClass=*)",
}
resp, err := conn.Search(req)
if err != nil {
return fmt.Errorf("search failed: %v", err)
}
if len(resp.Entries) == 0 {
// Are there cases were a user wouldn't be able to see their own entity?
return fmt.Errorf("user not found by search")
}
entry := resp.Entries[0]
email := entry.GetAttributeValue(c.emailAttribute)
if email == "" {
return fmt.Errorf("no email attribute found")
}
identity = &oidc.Identity{
ID: entry.DN,
Name: entry.GetAttributeValue(c.nameAttribute),
Email: email,
}
return nil
})
}
if err != nil {
return nil, err
}
return identity, nil
}