dex/connector/connector_ldap.go
Eric Chiang 8216a3d992 connector: fix path that connectors listen on
When Dex uses a non-root issuer URL, it current assumes that all
path prefixes will be trimmed by an upstream proxy (e.g. nginx).
This means that all paths rendered in HTML will be absolute to the
prefix, but the handlers still listen at the root.

Connectors are currently the only component that registers at a
non-root URL. Make this conform with the rest of Dex by having the
server determine the path the connector listens as rather than the
connector itself.
2016-07-25 14:32:24 -07:00

571 lines
15 KiB
Go

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
}