forked from mystiq/dex
8216a3d992
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.
570 lines
15 KiB
Go
570 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
|
|
}
|