Skip to content

Commit

Permalink
Add support to multiple LDAP Servers
Browse files Browse the repository at this point in the history
  • Loading branch information
wiltonsr committed Oct 10, 2024
1 parent 64fac47 commit 37e8653
Show file tree
Hide file tree
Showing 4 changed files with 151 additions and 77 deletions.
14 changes: 10 additions & 4 deletions examples/conf-from-labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,16 @@ services:
- traefik.http.routers.whoami.middlewares=ldap_auth
# ldapAuth Options=================================================================================
- traefik.http.middlewares.ldap_auth.plugin.ldapAuth.logLevel=DEBUG
- traefik.http.middlewares.ldap_auth.plugin.ldapAuth.url=ldap://ldap.forumsys.com
- traefik.http.middlewares.ldap_auth.plugin.ldapAuth.port=389
- traefik.http.middlewares.ldap_auth.plugin.ldapAuth.baseDN=dc=example,dc=com
- traefik.http.middlewares.ldap_auth.plugin.ldapAuth.attribute=uid
- traefik.http.middlewares.ldap_auth.plugin.ldapAuth.serverList[0].url=ldap://ldap.forumsys.com
- traefik.http.middlewares.ldap_auth.plugin.ldapAuth.serverList[0].port=389
- traefik.http.middlewares.ldap_auth.plugin.ldapAuth.serverList[0].weight=20
- traefik.http.middlewares.ldap_auth.plugin.ldapAuth.serverList[0].baseDN=dc=example,dc=com
- traefik.http.middlewares.ldap_auth.plugin.ldapAuth.serverList[0].attribute=uid
- traefik.http.middlewares.ldap_auth.plugin.ldapAuth.serverList[1].url=ldap://ldap2.forumsys.com
- traefik.http.middlewares.ldap_auth.plugin.ldapAuth.serverList[1].port=636
- traefik.http.middlewares.ldap_auth.plugin.ldapAuth.serverList[1].weight=10
- traefik.http.middlewares.ldap_auth.plugin.ldapAuth.serverList[1].baseDN=dc=example,dc=com
- traefik.http.middlewares.ldap_auth.plugin.ldapAuth.serverList[1].attribute=uid
# AllowedGroups and AllowedUsers are not supported with labels, because multiple value labels are separated with commas
# SearchFilter must not escape curly braces when using labels
# - traefik.http.middlewares.ldap_auth.plugin.ldapAuth.searchFilter=({{.Attribute}}={{.Username}})
Expand Down
10 changes: 7 additions & 3 deletions examples/dynamic-conf/ldapAuth-conf.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
[http.middlewares]
[http.middlewares.my-ldapAuth.plugin.ldapAuth]
Attribute = "uid"
BaseDN = "dc=example,dc=com"
Enabled = "true"
LogLevel = "DEBUG"
Port = "389"
[[http.middlewares.my-ldapAuth.plugin.ldapAuth.ServerList]]
Port = "636"
Url = "ldaps://ldap2.forumsys.com"
[[http.middlewares.my-ldapAuth.plugin.ldapAuth.ServerList]]
Url = "ldap://ldap.forumsys.com"
Port = "389"
Attribute = "uid"
BaseDN = "dc=example,dc=com"
AllowedGroups = ["ou=mathematicians,dc=example,dc=com","ou=italians,ou=scientists,dc=example,dc=com"]
AllowedUsers = ["euler", "uid=euclid,dc=example,dc=com"]
# SearchFilter must escape curly braces when using toml file
Expand Down
37 changes: 24 additions & 13 deletions examples/dynamic-conf/ldapAuth-conf.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,27 @@ http:
ldapAuth:
Enabled: true
LogLevel: "DEBUG"
Url: "ldap://ldap.forumsys.com"
Port: 389
BaseDN: "dc=example,dc=com"
Attribute: "uid"
AllowedGroups:
- ou=mathematicians,dc=example,dc=com
- ou=italians,ou=scientists,dc=example,dc=com
AllowedUsers:
- euler
- uid=euclid,dc=example,dc=com
# SearchFilter must escape curly braces when using yml file
# https://yaml.org/spec/1.1/#id872840
# SearchFilter: (\{\{.Attribute\}\}=\{\{.Username\}\})
ServerList:
- Url: "ldap://ldap.forumsys.com"
Port: 389
Weight: 100
BaseDN: "dc=example,dc=com"
Attribute: "uid"
AllowedGroups:
- ou=mathematicians,dc=example,dc=com
- ou=italians,ou=scientists,dc=example,dc=com
AllowedUsers:
- euler
- uid=euclid,dc=example,dc=com
# SearchFilter must escape curly braces when using yml file
# https://yaml.org/spec/1.1/#id872840
# SearchFilter: (\{\{.Attribute\}\}=\{\{.Username\}\})
- Url: "ldap://ldap4.forumsys.com"
Port: 636
Weight: 9
- Url: "ldap://ldap3.forumsys.com"
Port: 389
Weight: 11
- Url: "ldap://ldap2.forumsys.com"
Port: 636
Weight: 12
167 changes: 110 additions & 57 deletions ldapauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"net/url"
"os"
"reflect"
"sort"
"strconv"
"strings"
"text/template"
Expand All @@ -35,71 +36,80 @@ var (
LoggerERROR = log.New(ioutil.Discard, "ERROR: ldapAuth: ", log.Ldate|log.Ltime|log.Lshortfile)
)

type LdapServerConfig struct {
URL string `json:"url,omitempty" yaml:"url,omitempty"`
Port uint16 `json:"port,omitempty" yaml:"port,omitempty"`
Weight uint16 `json:"weight,omitempty" yaml:"weight,omitempty"`
StartTLS bool `json:"startTls,omitempty" yaml:"startTls,omitempty"`
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty" yaml:"insecureSkipVerify,omitempty"`
MinVersionTLS string `json:"minVersionTls,omitempty" yaml:"minVersionTls,omitempty"`
MaxVersionTLS string `json:"maxVersionTls,omitempty" yaml:"maxVersionTls,omitempty"`
CertificateAuthority string `json:"certificateAuthority,omitempty" yaml:"certificateAuthority,omitempty"`
Attribute string `json:"attribute,omitempty" yaml:"attribute,omitempty"`
SearchFilter string `json:"searchFilter,omitempty" yaml:"searchFilter,omitempty"`
BaseDN string `json:"baseDn,omitempty" yaml:"baseDn,omitempty"`
BindDN string `json:"bindDn,omitempty" yaml:"bindDn,omitempty"`
BindPassword string `json:"bindPassword,omitempty" yaml:"bindPassword,omitempty"`
EnableNestedGroupFilter bool `json:"enableNestedGroupsFilter,omitempty" yaml:"enableNestedGroupsFilter,omitempty"`
AllowedGroups []string `json:"allowedGroups,omitempty" yaml:"allowedGroups,omitempty"`
AllowedUsers []string `json:"allowedUsers,omitempty" yaml:"allowedUsers,omitempty"`
}

// Config the plugin configuration.
type Config struct {
Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
LogLevel string `json:"logLevel,omitempty" yaml:"logLevel,omitempty"`
URL string `json:"url,omitempty" yaml:"url,omitempty"`
Port uint16 `json:"port,omitempty" yaml:"port,omitempty"`
CacheTimeout uint32 `json:"cacheTimeout,omitempty" yaml:"cacheTimeout,omitempty"`
CacheCookieName string `json:"cacheCookieName,omitempty" yaml:"cacheCookieName,omitempty"`
CacheCookiePath string `json:"cacheCookiePath,omitempty" yaml:"cacheCookiePath,omitempty"`
CacheCookieSecure bool `json:"cacheCookieSecure,omitempty" yaml:"cacheCookieSecure,omitempty"`
CacheKey string `json:"cacheKey,omitempty" yaml:"cacheKey,omitempty"`
StartTLS bool `json:"startTls,omitempty" yaml:"startTls,omitempty"`
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty" yaml:"insecureSkipVerify,omitempty"`
MinVersionTLS string `json:"minVersionTls,omitempty" yaml:"minVersionTls,omitempty"`
MaxVersionTLS string `json:"maxVersionTls,omitempty" yaml:"maxVersionTls,omitempty"`
CertificateAuthority string `json:"certificateAuthority,omitempty" yaml:"certificateAuthority,omitempty"`
Attribute string `json:"attribute,omitempty" yaml:"attribute,omitempty"`
SearchFilter string `json:"searchFilter,omitempty" yaml:"searchFilter,omitempty"`
BaseDN string `json:"baseDn,omitempty" yaml:"baseDn,omitempty"`
BindDN string `json:"bindDn,omitempty" yaml:"bindDn,omitempty"`
BindPassword string `json:"bindPassword,omitempty" yaml:"bindPassword,omitempty"`
ForwardUsername bool `json:"forwardUsername,omitempty" yaml:"forwardUsername,omitempty"`
ForwardUsernameHeader string `json:"forwardUsernameHeader,omitempty" yaml:"forwardUsernameHeader,omitempty"`
ForwardAuthorization bool `json:"forwardAuthorization,omitempty" yaml:"forwardAuthorization,omitempty"`
ForwardExtraLdapHeaders bool `json:"forwardExtraLdapHeaders,omitempty" yaml:"forwardExtraLdapHeaders,omitempty"`
WWWAuthenticateHeader bool `json:"wwwAuthenticateHeader,omitempty" yaml:"wwwAuthenticateHeader,omitempty"`
WWWAuthenticateHeaderRealm string `json:"wwwAuthenticateHeaderRealm,omitempty" yaml:"wwwAuthenticateHeaderRealm,omitempty"`
EnableNestedGroupFilter bool `json:"enableNestedGroupsFilter,omitempty" yaml:"enableNestedGroupsFilter,omitempty"`
AllowedGroups []string `json:"allowedGroups,omitempty" yaml:"allowedGroups,omitempty"`
AllowedUsers []string `json:"allowedUsers,omitempty" yaml:"allowedUsers,omitempty"`
Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
LogLevel string `json:"logLevel,omitempty" yaml:"logLevel,omitempty"`
ServerList []LdapServerConfig `json:"serverList,omitempty" yaml:"serverList,omitempty"`
CacheTimeout uint32 `json:"cacheTimeout,omitempty" yaml:"cacheTimeout,omitempty"`
CacheCookieName string `json:"cacheCookieName,omitempty" yaml:"cacheCookieName,omitempty"`
CacheCookiePath string `json:"cacheCookiePath,omitempty" yaml:"cacheCookiePath,omitempty"`
CacheCookieSecure bool `json:"cacheCookieSecure,omitempty" yaml:"cacheCookieSecure,omitempty"`
CacheKey string `json:"cacheKey,omitempty" yaml:"cacheKey,omitempty"`
ForwardUsername bool `json:"forwardUsername,omitempty" yaml:"forwardUsername,omitempty"`
ForwardUsernameHeader string `json:"forwardUsernameHeader,omitempty" yaml:"forwardUsernameHeader,omitempty"`
ForwardAuthorization bool `json:"forwardAuthorization,omitempty" yaml:"forwardAuthorization,omitempty"`
ForwardExtraLdapHeaders bool `json:"forwardExtraLdapHeaders,omitempty" yaml:"forwardExtraLdapHeaders,omitempty"`
WWWAuthenticateHeader bool `json:"wwwAuthenticateHeader,omitempty" yaml:"wwwAuthenticateHeader,omitempty"`
WWWAuthenticateHeaderRealm string `json:"wwwAuthenticateHeaderRealm,omitempty" yaml:"wwwAuthenticateHeaderRealm,omitempty"`
Username string
// params below are deprecated
URL string `json:"url,omitempty" yaml:"url,omitempty"`
Port uint16 `json:"port,omitempty" yaml:"port,omitempty"`
StartTLS bool `json:"startTls,omitempty" yaml:"startTls,omitempty"`
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty" yaml:"insecureSkipVerify,omitempty"`
MinVersionTLS string `json:"minVersionTls,omitempty" yaml:"minVersionTls,omitempty"`
MaxVersionTLS string `json:"maxVersionTls,omitempty" yaml:"maxVersionTls,omitempty"`
CertificateAuthority string `json:"certificateAuthority,omitempty" yaml:"certificateAuthority,omitempty"`
Attribute string `json:"attribute,omitempty" yaml:"attribute,omitempty"`
SearchFilter string `json:"searchFilter,omitempty" yaml:"searchFilter,omitempty"`
BaseDN string `json:"baseDn,omitempty" yaml:"baseDn,omitempty"`
BindDN string `json:"bindDn,omitempty" yaml:"bindDn,omitempty"`
BindPassword string `json:"bindPassword,omitempty" yaml:"bindPassword,omitempty"`
EnableNestedGroupFilter bool `json:"enableNestedGroupsFilter,omitempty" yaml:"enableNestedGroupsFilter,omitempty"`
AllowedGroups []string `json:"allowedGroups,omitempty" yaml:"allowedGroups,omitempty"`
AllowedUsers []string `json:"allowedUsers,omitempty" yaml:"allowedUsers,omitempty"`
}

// CreateConfig creates the default plugin configuration.
func CreateConfig() *Config {
return &Config{
Enabled: true,
LogLevel: "INFO",
URL: "", // Supports: ldap://, ldaps://
Port: 389, // Usually 389 or 636
ServerList: []LdapServerConfig{},
CacheTimeout: 300, // In seconds, default to 5m
CacheCookieName: "ldapAuth_session_token",
CacheCookiePath: "",
CacheCookieSecure: false,
CacheKey: "super-secret-key",
StartTLS: false,
InsecureSkipVerify: false,
MinVersionTLS: "tls.VersionTLS12",
MaxVersionTLS: "tls.VersionTLS13",
CertificateAuthority: "",
Attribute: "cn", // Usually uid or sAMAccountname
SearchFilter: "",
BaseDN: "",
BindDN: "",
BindPassword: "",
ForwardUsername: true,
ForwardUsernameHeader: "Username",
ForwardAuthorization: false,
ForwardExtraLdapHeaders: false,
WWWAuthenticateHeader: true,
WWWAuthenticateHeaderRealm: "",
EnableNestedGroupFilter: false,
AllowedGroups: nil,
AllowedUsers: nil,
Username: "",
// deprecated
URL: "",
}
}

Expand All @@ -116,6 +126,36 @@ func New(ctx context.Context, next http.Handler, config *Config, name string) (h

LoggerINFO.Printf("Starting %s Middleware...", name)

// It means the user is passing the URL directly
if config.URL != "" {
LoggerERROR.Printf("Passing LDAP Server Attributes directly is deprecated, please use 'ServerList' instead")
server := LdapServerConfig{
URL: config.URL,
Port: config.Port,
Weight: 1,
StartTLS: config.StartTLS,
InsecureSkipVerify: config.InsecureSkipVerify,
MinVersionTLS: config.MinVersionTLS,
MaxVersionTLS: config.MaxVersionTLS,
CertificateAuthority: config.CertificateAuthority,
Attribute: config.Attribute,
SearchFilter: config.SearchFilter,
BaseDN: config.BaseDN,
BindDN: config.BindDN,
BindPassword: config.BindPassword,
EnableNestedGroupFilter: config.EnableNestedGroupFilter,
AllowedGroups: config.AllowedGroups,
AllowedUsers: config.AllowedUsers,
}

config.ServerList = append(config.ServerList, server)
}

// Rank LDAP servers based on weight. Higher weight, higher precedence
sort.Slice(config.ServerList, func(i, j int) bool {
return config.ServerList[i].Weight > config.ServerList[j].Weight
})

LogConfigParams(config)

// Create new session with CacheKey and CacheTimeout.
Expand Down Expand Up @@ -175,14 +215,27 @@ func (la *LdapAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) {

LoggerDEBUG.Println("No session found! Trying to authenticate in LDAP")

conn, err := Connect(la.config)
if err != nil {
LoggerERROR.Printf("%s", err)
RequireAuth(rw, req, la.config, err)
return
var conn *ldap.Conn = nil
var serverInUse LdapServerConfig

for i, s := range la.config.ServerList {
LoggerDEBUG.Printf("Attempt %d/%d", i+1, len(la.config.ServerList))

conn, err = Connect(s)
if err != nil {
LoggerERROR.Printf("%s", err)
} else {
serverInUse = s
break
}

if len(la.config.ServerList) == i-1 {
RequireAuth(rw, req, la.config, err)
return
}
}

isValidUser, entry, err := LdapCheckUser(conn, la.config, username, password)
isValidUser, entry, err := LdapCheckUser(conn, serverInUse, username, password)

if !isValidUser {
defer conn.Close()
Expand All @@ -192,7 +245,7 @@ func (la *LdapAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return
}

isAuthorized, err := LdapCheckUserAuthorized(conn, la.config, entry, username)
isAuthorized, err := LdapCheckUserAuthorized(conn, serverInUse, entry, username)
if !isAuthorized {
defer conn.Close()
LoggerERROR.Printf("%s", err)
Expand Down Expand Up @@ -242,7 +295,7 @@ func ServeAuthenicated(la *LdapAuth, session *sessions.Session, rw http.Response
}

// LdapCheckUser check if user and password are correct.
func LdapCheckUser(conn *ldap.Conn, config *Config, username, password string) (bool, *ldap.Entry, error) {
func LdapCheckUser(conn *ldap.Conn, config LdapServerConfig, username, password string) (bool, *ldap.Entry, error) {
if config.SearchFilter == "" {
LoggerDEBUG.Printf("Running in Bind Mode")
userDN := fmt.Sprintf("%s=%s,%s", config.Attribute, username, config.BaseDN)
Expand Down Expand Up @@ -274,7 +327,7 @@ func LdapCheckUser(conn *ldap.Conn, config *Config, username, password string) (
}

// LdapCheckUserAuthorized check if user is authorized post-authentication
func LdapCheckUserAuthorized(conn *ldap.Conn, config *Config, entry *ldap.Entry, username string) (bool, error) {
func LdapCheckUserAuthorized(conn *ldap.Conn, config LdapServerConfig, entry *ldap.Entry, username string) (bool, error) {
// Check if authorization is required or simply authentication
if len(config.AllowedUsers) == 0 && len(config.AllowedGroups) == 0 {
LoggerDEBUG.Printf("No authorization requirements")
Expand Down Expand Up @@ -304,7 +357,7 @@ func LdapCheckUserAuthorized(conn *ldap.Conn, config *Config, entry *ldap.Entry,
}

// LdapCheckAllowedUsers check if user is explicitly allowed in AllowedUsers list
func LdapCheckAllowedUsers(conn *ldap.Conn, config *Config, entry *ldap.Entry, username string) bool {
func LdapCheckAllowedUsers(conn *ldap.Conn, config LdapServerConfig, entry *ldap.Entry, username string) bool {
if len(config.AllowedUsers) == 0 {
return false
}
Expand All @@ -323,7 +376,7 @@ func LdapCheckAllowedUsers(conn *ldap.Conn, config *Config, entry *ldap.Entry, u
}

// LdapCheckUserGroups check if the is user is a member of any of the AllowedGroups list
func LdapCheckUserGroups(conn *ldap.Conn, config *Config, entry *ldap.Entry, username string) (bool, error) {
func LdapCheckUserGroups(conn *ldap.Conn, config LdapServerConfig, entry *ldap.Entry, username string) (bool, error) {

if len(config.AllowedGroups) == 0 {
return false, nil
Expand Down Expand Up @@ -415,7 +468,7 @@ func RequireAuth(w http.ResponseWriter, req *http.Request, config *Config, err .
}

// Connect return a LDAP Connection.
func Connect(config *Config) (*ldap.Conn, error) {
func Connect(config LdapServerConfig) (*ldap.Conn, error) {
var conn *ldap.Conn = nil
var certPool *x509.CertPool
var err error = nil
Expand Down Expand Up @@ -466,7 +519,7 @@ func Connect(config *Config) (*ldap.Conn, error) {
}

// SearchMode make search to LDAP and return results.
func SearchMode(conn *ldap.Conn, config *Config) (*ldap.SearchResult, error) {
func SearchMode(conn *ldap.Conn, config LdapServerConfig) (*ldap.SearchResult, error) {
if config.BindDN != "" && config.BindPassword != "" {
LoggerDEBUG.Printf("Performing User BindDN Search")
err := conn.Bind(config.BindDN, config.BindPassword)
Expand Down Expand Up @@ -514,7 +567,7 @@ func SearchMode(conn *ldap.Conn, config *Config) (*ldap.SearchResult, error) {
}

// ParseSearchFilter remove spaces and trailing from searchFilter.
func ParseSearchFilter(config *Config) (string, error) {
func ParseSearchFilter(config LdapServerConfig) (string, error) {
filter := config.SearchFilter

filter = strings.Trim(filter, "\n\t")
Expand Down

0 comments on commit 37e8653

Please sign in to comment.