From d4afb027e59356442be7e702d64097c0da78bbe2 Mon Sep 17 00:00:00 2001 From: Matt Ouille Date: Sun, 9 Feb 2020 00:17:10 -0800 Subject: [PATCH 1/5] Add LDAP support --- irc/accounts.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++++- irc/config.go | 23 +++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/irc/accounts.go b/irc/accounts.go index 67e3d0c5..821d77a9 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -4,6 +4,7 @@ package irc import ( + "crypto/tls" "encoding/json" "fmt" "net/smtp" @@ -14,6 +15,7 @@ import ( "time" "unicode" + "github.com/go-ldap/ldap" "github.com/oragono/oragono/irc/caps" "github.com/oragono/oragono/irc/passwd" "github.com/oragono/oragono/irc/utils" @@ -828,8 +830,80 @@ func (am *AccountManager) checkPassphrase(accountName, passphrase string) (accou return } +func (am *AccountManager) checkLDAPPassphrase(accountName, passphrase string) (account ClientAccount, err error) { + var ( + host, url string + port int + ) + + host = am.server.AccountConfig().LDAP.Servers.Host + port = am.server.AccountConfig().LDAP.Servers.Port + + account, err = am.LoadAccount(accountName) + if err != nil { + return + } + + if !account.Verified { + err = errAccountUnverified + return + } + + if am.server.AccountConfig().LDAP.Servers.UseSSL { + url = fmt.Sprintf("ldaps://%s:%d", host, port) + } else { + url = fmt.Sprintf("ldap://%s:%d", host, port) + } + + l, err := ldap.DialURL(url) + if err != nil { + return + } + defer l.Close() + + if am.server.AccountConfig().LDAP.Servers.StartTLS { + err = l.StartTLS(&tls.Config{InsecureSkipVerify: am.server.AccountConfig().LDAP.Servers.SkipTLSVerify}) + if err != nil { + return + } + } + + err = l.Bind(am.server.AccountConfig().LDAP.BindDN, am.server.AccountConfig().LDAP.BindPwd) + if err != nil { + return + } + + for _, baseDN := range am.server.AccountConfig().LDAP.SearchBaseDNs { + req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, am.server.AccountConfig().LDAP.Timeout, false, fmt.Sprintf("(&(objectClass=organizationalPerson)(uid=%s))", accountName), []string{"dn"}, nil) + sr, err := l.Search(req) + if err != nil { + return + } + + userdn := sr.Entries[0].DN + + if len(sr.Entries) > 0 { + // verify the user passphrase + err = l.Bind(userdn, passphrase) + if err != nil { + continue + } + break + } + } + + return +} + func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName string, passphrase string) error { - account, err := am.checkPassphrase(accountName, passphrase) + var account ClientAccount + var err error + + if am.server.AccountConfig().LDAP.Enabled { + account, err = am.checkLDAPPassphrase(accountName, passphrase) + } + + account, err = am.checkPassphrase(accountName, passphrase) if err != nil { return err } diff --git a/irc/config.go b/irc/config.go index 361cd15f..6a315835 100644 --- a/irc/config.go +++ b/irc/config.go @@ -68,6 +68,7 @@ type AccountConfig struct { Exempted []string exemptedNets []net.IPNet } `yaml:"require-sasl"` + LDAP LDAPConfig LoginThrottling struct { Enabled bool Duration time.Duration @@ -82,6 +83,28 @@ type AccountConfig struct { VHosts VHostConfig } +type LDAPConfig struct { + Timeout int + Enabled bool + AllowSignup bool `yaml:"allow-signup"` + BindDN string `yaml:"bind-dn"` + BindPwd string `yaml:"bind-password"` + SearchFilter string `yaml:"search-filter"` + SearchBaseDNs []string `yaml:"search-base-dns"` + Attributes map[string]string + Servers LDAPServerConfig +} + +type LDAPServerConfig struct { + Host string + Port int + UseSSL bool `yaml:"use-ssl"` + StartTLS bool `yaml:"start-tls"` + SkipTLSVerify bool `yaml:"skip-tls-verify"` + ClientCert string `yaml:"client-cert"` + ClientKey string `yaml:"client-key"` +} + // AccountRegistrationConfig controls account registration. type AccountRegistrationConfig struct { Enabled bool From 5ba2527eb01d2d6e46510a182792d4b3efb47aae Mon Sep 17 00:00:00 2001 From: Matt Ouille Date: Sun, 9 Feb 2020 01:12:42 -0800 Subject: [PATCH 2/5] Fix compilation errors --- irc/accounts.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/irc/accounts.go b/irc/accounts.go index 821d77a9..9638d699 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -834,6 +834,8 @@ func (am *AccountManager) checkLDAPPassphrase(accountName, passphrase string) (a var ( host, url string port int + sr *ldap.SearchResult + l *ldap.Conn ) host = am.server.AccountConfig().LDAP.Servers.Host @@ -855,7 +857,7 @@ func (am *AccountManager) checkLDAPPassphrase(accountName, passphrase string) (a url = fmt.Sprintf("ldap://%s:%d", host, port) } - l, err := ldap.DialURL(url) + l, err = ldap.DialURL(url) if err != nil { return } @@ -875,7 +877,7 @@ func (am *AccountManager) checkLDAPPassphrase(accountName, passphrase string) (a for _, baseDN := range am.server.AccountConfig().LDAP.SearchBaseDNs { req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, am.server.AccountConfig().LDAP.Timeout, false, fmt.Sprintf("(&(objectClass=organizationalPerson)(uid=%s))", accountName), []string{"dn"}, nil) - sr, err := l.Search(req) + sr, err = l.Search(req) if err != nil { return } @@ -901,6 +903,10 @@ func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName s if am.server.AccountConfig().LDAP.Enabled { account, err = am.checkLDAPPassphrase(accountName, passphrase) + if err == nil { + am.Login(client, account) + return nil + } } account, err = am.checkPassphrase(accountName, passphrase) From c13597f80719e4c8ed34a955d921c9e321644341 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Tue, 11 Feb 2020 06:35:17 -0500 Subject: [PATCH 3/5] additional LDAP support --- go.mod | 1 + irc/accounts.go | 118 ++++++---------- irc/config.go | 25 +--- irc/errors.go | 1 + irc/ldap/config.go | 63 +++++++++ irc/ldap/helpers.go | 51 +++++++ irc/ldap/login.go | 323 ++++++++++++++++++++++++++++++++++++++++++++ irc/nickserv.go | 20 +-- oragono.yaml | 30 ++++ vendor | 2 +- 10 files changed, 521 insertions(+), 113 deletions(-) create mode 100644 irc/ldap/config.go create mode 100644 irc/ldap/helpers.go create mode 100644 irc/ldap/login.go diff --git a/go.mod b/go.mod index da6146a2..bc4d9b04 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.14 require ( code.cloudfoundry.org/bytefmt v0.0.0-20190819182555-854d396b647c github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 + github.com/go-ldap/ldap/v3 v3.1.6 github.com/goshuirc/e-nfa v0.0.0-20160917075329-7071788e3940 // indirect github.com/goshuirc/irc-go v0.0.0-20190713001546-05ecc95249a0 github.com/mattn/go-colorable v0.1.4 diff --git a/irc/accounts.go b/irc/accounts.go index 9638d699..0bf822c7 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -4,7 +4,6 @@ package irc import ( - "crypto/tls" "encoding/json" "fmt" "net/smtp" @@ -15,8 +14,8 @@ import ( "time" "unicode" - "github.com/go-ldap/ldap" "github.com/oragono/oragono/irc/caps" + "github.com/oragono/oragono/irc/ldap" "github.com/oragono/oragono/irc/passwd" "github.com/oragono/oragono/irc/utils" "github.com/tidwall/buntdb" @@ -448,6 +447,10 @@ func (am *AccountManager) setPassword(account string, password string, hasPrivs return err } + if !hasPrivs && creds.Empty() { + return errCredsExternallyManaged + } + err = creds.SetPassphrase(password, am.server.Config().Accounts.Registration.BcryptCost) if err != nil { return err @@ -502,6 +505,10 @@ func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasP return err } + if !hasPrivs && creds.Empty() { + return errCredsExternallyManaged + } + if add { err = creds.AddCertfp(certfp) } else { @@ -688,6 +695,15 @@ func (am *AccountManager) Verify(client *Client, account string, code string) er return nil } +// register and verify an account, for internal use +func (am *AccountManager) SARegister(account, passphrase string) (err error) { + err = am.Register(nil, account, "admin", "", passphrase, "") + if err == nil { + err = am.Verify(nil, account, "") + } + return +} + func marshalReservedNicks(nicks []string) string { return strings.Join(nicks, ",") } @@ -830,92 +846,34 @@ func (am *AccountManager) checkPassphrase(accountName, passphrase string) (accou return } -func (am *AccountManager) checkLDAPPassphrase(accountName, passphrase string) (account ClientAccount, err error) { - var ( - host, url string - port int - sr *ldap.SearchResult - l *ldap.Conn - ) - - host = am.server.AccountConfig().LDAP.Servers.Host - port = am.server.AccountConfig().LDAP.Servers.Port - - account, err = am.LoadAccount(accountName) - if err != nil { - return - } - - if !account.Verified { - err = errAccountUnverified - return - } - - if am.server.AccountConfig().LDAP.Servers.UseSSL { - url = fmt.Sprintf("ldaps://%s:%d", host, port) - } else { - url = fmt.Sprintf("ldap://%s:%d", host, port) - } - - l, err = ldap.DialURL(url) - if err != nil { - return - } - defer l.Close() - - if am.server.AccountConfig().LDAP.Servers.StartTLS { - err = l.StartTLS(&tls.Config{InsecureSkipVerify: am.server.AccountConfig().LDAP.Servers.SkipTLSVerify}) - if err != nil { - return - } - } - - err = l.Bind(am.server.AccountConfig().LDAP.BindDN, am.server.AccountConfig().LDAP.BindPwd) - if err != nil { - return - } - - for _, baseDN := range am.server.AccountConfig().LDAP.SearchBaseDNs { - req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, am.server.AccountConfig().LDAP.Timeout, false, fmt.Sprintf("(&(objectClass=organizationalPerson)(uid=%s))", accountName), []string{"dn"}, nil) - sr, err = l.Search(req) - if err != nil { - return - } - - userdn := sr.Entries[0].DN - - if len(sr.Entries) > 0 { - // verify the user passphrase - err = l.Bind(userdn, passphrase) - if err != nil { - continue - } - break - } - } - - return -} - -func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName string, passphrase string) error { +func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName string, passphrase string) (err error) { var account ClientAccount - var err error - if am.server.AccountConfig().LDAP.Enabled { - account, err = am.checkLDAPPassphrase(accountName, passphrase) + defer func() { if err == nil { am.Login(client, account) - return nil + } + }() + + ldapConf := am.server.Config().Accounts.LDAP + if ldapConf.Enabled { + err = ldap.CheckLDAPPassphrase(ldapConf, accountName, passphrase, am.server.logger) + if err == nil { + account, err = am.LoadAccount(accountName) + // autocreate if necessary: + if err == errAccountDoesNotExist && ldapConf.Autocreate { + err = am.SARegister(accountName, "") + if err != nil { + return + } + account, err = am.LoadAccount(accountName) + } + return } } account, err = am.checkPassphrase(accountName, passphrase) - if err != nil { - return err - } - - am.Login(client, account) - return nil + return err } func (am *AccountManager) LoadAccount(accountName string) (result ClientAccount, err error) { diff --git a/irc/config.go b/irc/config.go index 6a315835..d64fb93b 100644 --- a/irc/config.go +++ b/irc/config.go @@ -25,6 +25,7 @@ import ( "github.com/oragono/oragono/irc/custime" "github.com/oragono/oragono/irc/isupport" "github.com/oragono/oragono/irc/languages" + "github.com/oragono/oragono/irc/ldap" "github.com/oragono/oragono/irc/logger" "github.com/oragono/oragono/irc/modes" "github.com/oragono/oragono/irc/passwd" @@ -68,7 +69,7 @@ type AccountConfig struct { Exempted []string exemptedNets []net.IPNet } `yaml:"require-sasl"` - LDAP LDAPConfig + LDAP ldap.LDAPConfig LoginThrottling struct { Enabled bool Duration time.Duration @@ -83,28 +84,6 @@ type AccountConfig struct { VHosts VHostConfig } -type LDAPConfig struct { - Timeout int - Enabled bool - AllowSignup bool `yaml:"allow-signup"` - BindDN string `yaml:"bind-dn"` - BindPwd string `yaml:"bind-password"` - SearchFilter string `yaml:"search-filter"` - SearchBaseDNs []string `yaml:"search-base-dns"` - Attributes map[string]string - Servers LDAPServerConfig -} - -type LDAPServerConfig struct { - Host string - Port int - UseSSL bool `yaml:"use-ssl"` - StartTLS bool `yaml:"start-tls"` - SkipTLSVerify bool `yaml:"skip-tls-verify"` - ClientCert string `yaml:"client-cert"` - ClientKey string `yaml:"client-key"` -} - // AccountRegistrationConfig controls account registration. type AccountRegistrationConfig struct { Enabled bool diff --git a/irc/errors.go b/irc/errors.go index a3a32c6e..e90f86eb 100644 --- a/irc/errors.go +++ b/irc/errors.go @@ -55,6 +55,7 @@ var ( errNoop = errors.New("Action was a no-op") errCASFailed = errors.New("Compare-and-swap update of database value failed") errEmptyCredentials = errors.New("No more credentials are approved") + errCredsExternallyManaged = errors.New("Credentials are externally managed and cannot be changed here") ) // Socket Errors diff --git a/irc/ldap/config.go b/irc/ldap/config.go new file mode 100644 index 00000000..6eaf4ee3 --- /dev/null +++ b/irc/ldap/config.go @@ -0,0 +1,63 @@ +// Copyright (c) 2020 Matt Ouille +// Copyright (c) 2020 Shivaram Lingamneni +// released under the MIT license + +// Portions of this code copyright Grafana Labs and contributors +// and released under the Apache 2.0 license + +package ldap + +import ( + "fmt" + "strings" + "time" +) + +type LDAPConfig struct { + Enabled bool + Autocreate bool + + Host string + Port int + Timeout time.Duration + UseSSL bool `yaml:"use-ssl"` + StartTLS bool `yaml:"start-tls"` + SkipTLSVerify bool `yaml:"skip-tls-verify"` + RootCACert string `yaml:"root-ca-cert"` + ClientCert string `yaml:"client-cert"` + ClientKey string `yaml:"client-key"` + + BindDN string `yaml:"bind-dn"` + BindPassword string `yaml:"bind-password"` + SearchFilter string `yaml:"search-filter"` + SearchBaseDNs []string `yaml:"search-base-dns"` + + // user validation: require them to be in any one of these groups + RequireGroups []string `yaml:"require-groups"` + + // two ways of testing group membership: either via an attribute + // of the user's DN, typically named 'memberOf', but customizable: + MemberOfAttribute string `yaml:"member-of-attribute"` + // or by searching for groups that match the user's DN + // and testing their names: + GroupSearchFilter string `yaml:"group-search-filter"` + GroupSearchFilterUserAttribute string `yaml:"group-search-filter-user-attribute"` + GroupSearchBaseDNs []string `yaml:"group-search-base-dns"` +} + +// shouldAdminBind checks if we should use +// admin username & password for LDAP bind +func (config *LDAPConfig) shouldAdminBind() bool { + return config.BindPassword != "" +} + +// shouldSingleBind checks if we can use "single bind" approach +func (config *LDAPConfig) shouldSingleBind() bool { + return strings.Contains(config.BindDN, "%s") +} + +// singleBindDN combines the bind with the username +// in order to get the proper path +func (config *LDAPConfig) singleBindDN(username string) string { + return fmt.Sprintf(config.BindDN, username) +} diff --git a/irc/ldap/helpers.go b/irc/ldap/helpers.go new file mode 100644 index 00000000..12cee6ad --- /dev/null +++ b/irc/ldap/helpers.go @@ -0,0 +1,51 @@ +// Copyright Grafana Labs and contributors +// and released under the Apache 2.0 license + +package ldap + +import ( + "strings" + + ldap "github.com/go-ldap/ldap/v3" +) + +func isMemberOf(memberOf []string, group string) bool { + if group == "*" { + return true + } + + for _, member := range memberOf { + if strings.EqualFold(member, group) { + return true + } + } + return false +} + +func getArrayAttribute(name string, entry *ldap.Entry) []string { + if strings.ToLower(name) == "dn" { + return []string{entry.DN} + } + + for _, attr := range entry.Attributes { + if attr.Name == name && len(attr.Values) > 0 { + return attr.Values + } + } + return []string{} +} + +func getAttribute(name string, entry *ldap.Entry) string { + if strings.ToLower(name) == "dn" { + return entry.DN + } + + for _, attr := range entry.Attributes { + if attr.Name == name { + if len(attr.Values) > 0 { + return attr.Values[0] + } + } + } + return "" +} diff --git a/irc/ldap/login.go b/irc/ldap/login.go new file mode 100644 index 00000000..cc6938fc --- /dev/null +++ b/irc/ldap/login.go @@ -0,0 +1,323 @@ +// Copyright (c) 2020 Matt Ouille +// Copyright (c) 2020 Shivaram Lingamneni +// released under the MIT license + +// Portions of this code copyright Grafana Labs and contributors +// and released under the Apache 2.0 license + +// Copying Grafana's original comment on the different cases for LDAP: +// There are several cases - +// 1. "admin" user +// Bind the "admin" user (defined in Grafana config file) which has the search privileges +// in LDAP server, then we search the targeted user through that bind, then the second +// perform the bind via passed login/password. +// 2. Single bind +// // If all the users meant to be used with Grafana have the ability to search in LDAP server +// then we bind with LDAP server with targeted login/password +// and then search for the said user in order to retrive all the information about them +// 3. Unauthenticated bind +// For some LDAP configurations it is allowed to search the +// user without login/password binding with LDAP server, in such case +// we will perform "unauthenticated bind", then search for the +// targeted user and then perform the bind with passed login/password. + +// Note: the only validation we do on users is to check RequiredGroups. +// If RequiredGroups is not set and we can do a single bind, we don't +// even need to search. So our case 2 is not restricted +// to setups where all the users have search privileges: we only need to +// be able to do DN resolution via pure string substitution. + +package ldap + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io/ioutil" + "strings" + + ldap "github.com/go-ldap/ldap/v3" + + "github.com/oragono/oragono/irc/logger" +) + +var ( + ErrCouldNotFindUser = errors.New("No such user") + ErrUserNotInRequiredGroup = errors.New("User is not a member of any required groups") + ErrInvalidCredentials = errors.New("Invalid credentials") +) + +func CheckLDAPPassphrase(config LDAPConfig, accountName, passphrase string, log *logger.Manager) (err error) { + defer func() { + if err != nil { + log.Debug("ldap", "failed passphrase check", err.Error()) + } + }() + + l, err := dial(&config) + if err != nil { + return + } + defer l.Close() + + l.SetTimeout(config.Timeout) + + passphraseChecked := false + + if config.shouldSingleBind() { + log.Debug("ldap", "attempting single bind to", accountName) + err = l.Bind(config.singleBindDN(accountName), passphrase) + passphraseChecked = (err == nil) + } else if config.shouldAdminBind() { + log.Debug("ldap", "attempting admin bind to", config.BindDN) + err = l.Bind(config.BindDN, config.BindPassword) + } else { + log.Debug("ldap", "attempting unauthenticated bind") + err = l.UnauthenticatedBind(config.BindDN) + } + + if err != nil { + return + } + + if passphraseChecked && len(config.RequireGroups) == 0 { + return nil + } + + users, err := lookupUsers(l, &config, accountName) + if err != nil { + log.Debug("ldap", "failed user lookup") + return err + } + + if len(users) == 0 { + return ErrCouldNotFindUser + } + + user := users[0] + + log.Debug("ldap", "looked up user", user.DN) + + err = validateGroupMembership(l, &config, user, log) + if err != nil { + return err + } + + if !passphraseChecked { + // Authenticate user + log.Debug("ldap", "rebinding", user.DN) + err = l.Bind(user.DN, passphrase) + if err != nil { + log.Debug("ldap", "failed rebind", err.Error()) + if ldapErr, ok := err.(*ldap.Error); ok { + if ldapErr.ResultCode == 49 { + return ErrInvalidCredentials + } + } + } + return err + } + + return nil +} + +func dial(config *LDAPConfig) (conn *ldap.Conn, err error) { + var certPool *x509.CertPool + if config.RootCACert != "" { + certPool = x509.NewCertPool() + for _, caCertFile := range strings.Split(config.RootCACert, " ") { + pem, err := ioutil.ReadFile(caCertFile) + if err != nil { + return nil, err + } + if !certPool.AppendCertsFromPEM(pem) { + return nil, errors.New("Failed to append CA certificate " + caCertFile) + } + } + } + var clientCert tls.Certificate + if config.ClientCert != "" && config.ClientKey != "" { + clientCert, err = tls.LoadX509KeyPair(config.ClientCert, config.ClientKey) + if err != nil { + return + } + } + for _, host := range strings.Split(config.Host, " ") { + address := fmt.Sprintf("%s:%d", host, config.Port) + if config.UseSSL { + tlsCfg := &tls.Config{ + InsecureSkipVerify: config.SkipTLSVerify, + ServerName: host, + RootCAs: certPool, + } + if len(clientCert.Certificate) > 0 { + tlsCfg.Certificates = append(tlsCfg.Certificates, clientCert) + } + if config.StartTLS { + conn, err = ldap.Dial("tcp", address) + if err == nil { + if err = conn.StartTLS(tlsCfg); err == nil { + return + } + } + } else { + conn, err = ldap.DialTLS("tcp", address, tlsCfg) + } + } else { + conn, err = ldap.Dial("tcp", address) + } + + if err == nil { + return + } + } + return +} + +func validateGroupMembership(conn *ldap.Conn, config *LDAPConfig, user *ldap.Entry, log *logger.Manager) (err error) { + if len(config.RequireGroups) != 0 { + var memberOf []string + memberOf, err = getMemberOf(conn, config, user) + if err != nil { + log.Debug("ldap", "could not retrieve group memberships", err.Error()) + return + } + log.Debug("ldap", fmt.Sprintf("found group memberships: %v", memberOf)) + foundGroup := false + for _, inGroup := range memberOf { + for _, acceptableGroup := range config.RequireGroups { + if inGroup == acceptableGroup { + foundGroup = true + break + } + } + if foundGroup { + break + } + } + if !foundGroup { + return ErrUserNotInRequiredGroup + } + } + return nil +} + +func lookupUsers(conn *ldap.Conn, config *LDAPConfig, accountName string) (results []*ldap.Entry, err error) { + var result *ldap.SearchResult + + for _, base := range config.SearchBaseDNs { + result, err = conn.Search( + getSearchRequest(config, base, accountName), + ) + if err != nil { + return nil, err + } else if len(result.Entries) > 0 { + return result.Entries, nil + } + } + + return nil, nil +} + +// getSearchRequest returns LDAP search request for users +func getSearchRequest( + config *LDAPConfig, + base string, + accountName string, +) *ldap.SearchRequest { + + var attributes []string + if config.MemberOfAttribute != "" { + attributes = []string{config.MemberOfAttribute} + } + + query := strings.Replace( + config.SearchFilter, + "%s", ldap.EscapeFilter(accountName), + -1, + ) + + return &ldap.SearchRequest{ + BaseDN: base, + Scope: ldap.ScopeWholeSubtree, + DerefAliases: ldap.NeverDerefAliases, + Attributes: attributes, + Filter: query, + } +} + +// getMemberOf finds memberOf property or request it +func getMemberOf(conn *ldap.Conn, config *LDAPConfig, result *ldap.Entry) ( + []string, error, +) { + if config.GroupSearchFilter == "" { + memberOf := getArrayAttribute(config.MemberOfAttribute, result) + + return memberOf, nil + } + + memberOf, err := requestMemberOf(conn, config, result) + if err != nil { + return nil, err + } + + return memberOf, nil +} + +// requestMemberOf use this function when POSIX LDAP +// schema does not support memberOf, so it manually search the groups +func requestMemberOf(conn *ldap.Conn, config *LDAPConfig, entry *ldap.Entry) ([]string, error) { + var memberOf []string + + for _, groupSearchBase := range config.GroupSearchBaseDNs { + var filterReplace string + if config.GroupSearchFilterUserAttribute == "" { + filterReplace = "cn" + } else { + filterReplace = getAttribute( + config.GroupSearchFilterUserAttribute, + entry, + ) + } + + filter := strings.Replace( + config.GroupSearchFilter, "%s", + ldap.EscapeFilter(filterReplace), + -1, + ) + + // support old way of reading settings + groupIDAttribute := config.MemberOfAttribute + // but prefer dn attribute if default settings are used + if groupIDAttribute == "" || groupIDAttribute == "memberOf" { + groupIDAttribute = "dn" + } + + groupSearchReq := ldap.SearchRequest{ + BaseDN: groupSearchBase, + Scope: ldap.ScopeWholeSubtree, + DerefAliases: ldap.NeverDerefAliases, + Attributes: []string{groupIDAttribute}, + Filter: filter, + } + + groupSearchResult, err := conn.Search(&groupSearchReq) + if err != nil { + return nil, err + } + + if len(groupSearchResult.Entries) > 0 { + for _, group := range groupSearchResult.Entries { + + memberOf = append( + memberOf, + getAttribute(groupIDAttribute, group), + ) + } + break + } + } + + return memberOf, nil +} diff --git a/irc/nickserv.go b/irc/nickserv.go index 103cb8aa..16177c75 100644 --- a/irc/nickserv.go +++ b/irc/nickserv.go @@ -132,7 +132,7 @@ SADROP forcibly de-links the given nickname from the attached user account.`, }, "saregister": { handler: nsSaregisterHandler, - help: `Syntax: $bSAREGISTER $b + help: `Syntax: $bSAREGISTER [password]$b SAREGISTER registers an account on someone else's behalf. This is for use in configurations that require SASL for all connections; @@ -140,7 +140,7 @@ an administrator can set use this command to set up user accounts.`, helpShort: `$bSAREGISTER$b registers an account on someone else's behalf.`, enabled: servCmdRequiresAuthEnabled, capabs: []string{"accreg"}, - minParams: 2, + minParams: 1, }, "sessions": { handler: nsSessionsHandler, @@ -681,14 +681,12 @@ func nsRegisterHandler(server *Server, client *Client, command string, params [] } func nsSaregisterHandler(server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { - account, passphrase := params[0], params[1] - if passphrase == "*" { - passphrase = "" - } - err := server.accounts.Register(nil, account, "admin", "", passphrase, "") - if err == nil { - err = server.accounts.Verify(nil, account, "") + var account, passphrase string + account = params[0] + if 1 < len(params) && params[1] != "*" { + passphrase = params[1] } + err := server.accounts.SARegister(account, passphrase) if err != nil { var errMsg string @@ -830,6 +828,8 @@ func nsPasswdHandler(server *Server, client *Client, command string, params []st nsNotice(rb, client.t("Password changed")) case errEmptyCredentials: nsNotice(rb, client.t("You can't delete your password unless you add a certificate fingerprint")) + case errCredsExternallyManaged: + nsNotice(rb, client.t("Your account credentials are managed externally and cannot be changed here")) case errCASFailed: nsNotice(rb, client.t("Try again later")) default: @@ -961,6 +961,8 @@ func nsCertHandler(server *Server, client *Client, command string, params []stri nsNotice(rb, client.t("That certificate fingerprint is already associated with another account")) case errEmptyCredentials: nsNotice(rb, client.t("You can't remove all your certificate fingerprints unless you add a password")) + case errCredsExternallyManaged: + nsNotice(rb, client.t("Your account credentials are managed externally and cannot be changed here")) case errCASFailed: nsNotice(rb, client.t("Try again later")) default: diff --git a/oragono.yaml b/oragono.yaml index 54131ad2..d1e661e8 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -384,6 +384,36 @@ accounts: offer-list: #- "oragono.test" + # support for deferring password checking to an external LDAP server + # you should probably ignore this section! consult the grafana docs for details: + # https://grafana.com/docs/grafana/latest/auth/ldap/ + # ldap: + # enabled: true + # # should we automatically create users if their LDAP login succeeds? + # autocreate: true + # host: "ldap.forumsys.com" + # port: 389 + # timeout: 30s + # # example "single-bind" configuration, where we bind directly to the user's entry: + # bind-dn: "uid=%s,dc=example,dc=com" + # # example "admin bind" configuration, where we bind to an initial admin user, + # # then search for the user's entry with a search filter: + # #search-base-dns: + # # - "dc=example,dc=com" + # #bind-dn: "cn=read-only-admin,dc=example,dc=com" + # #bind-password: "password" + # #search-filter: "(uid=%s)" + # # example of requiring that users be in a particular group: + # #require-groups: + # # - "ou=mathematicians,dc=example,dc=com" + # #group-search-filter-user-attribute: "dn" + # #group-search-filter: "(uniqueMember=%s)" + # #group-search-base-dns: + # # - "dc=example,dc=com" + # # example of group membership testing via user attributes, as in AD + # # or with OpenLDAP's "memberOf overlay" (overrides group-search-filter): + # #member-of-attribute: "memberOf" + # channel options channels: # modes that are set when new channels are created diff --git a/vendor b/vendor index 269a9c04..6e49b8a2 160000 --- a/vendor +++ b/vendor @@ -1 +1 @@ -Subproject commit 269a9c041579d103a1cab3ca989174e63040a7c9 +Subproject commit 6e49b8a260f1ba3351c17876c2e2d0044c315078 From 0c2d8adeacd2a84a0186bc161a52e47f029a0f6c Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Tue, 11 Feb 2020 16:09:43 -0500 Subject: [PATCH 4/5] improve maintainability and license compliance 0. Maximum parity with upstream code 1. Added Apache-required modification notices 2. Added Apache license --- irc/config.go | 2 +- irc/ldap/LICENSE | 202 +++++++++++++++++++++++++++++++++ irc/ldap/config.go | 51 ++++----- irc/ldap/grafana.go | 266 ++++++++++++++++++++++++++++++++++++++++++++ irc/ldap/helpers.go | 13 ++- irc/ldap/login.go | 263 ++++++++----------------------------------- oragono.yaml | 4 +- 7 files changed, 551 insertions(+), 250 deletions(-) create mode 100644 irc/ldap/LICENSE create mode 100644 irc/ldap/grafana.go diff --git a/irc/config.go b/irc/config.go index d64fb93b..1944f383 100644 --- a/irc/config.go +++ b/irc/config.go @@ -69,7 +69,7 @@ type AccountConfig struct { Exempted []string exemptedNets []net.IPNet } `yaml:"require-sasl"` - LDAP ldap.LDAPConfig + LDAP ldap.ServerConfig LoginThrottling struct { Enabled bool Duration time.Duration diff --git a/irc/ldap/LICENSE b/irc/ldap/LICENSE new file mode 100644 index 00000000..373dde57 --- /dev/null +++ b/irc/ldap/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2015 Grafana Labs + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/irc/ldap/config.go b/irc/ldap/config.go index 6eaf4ee3..cfd981e1 100644 --- a/irc/ldap/config.go +++ b/irc/ldap/config.go @@ -1,19 +1,21 @@ -// Copyright (c) 2020 Matt Ouille -// Copyright (c) 2020 Shivaram Lingamneni -// released under the MIT license +// Copyright 2014-2018 Grafana Labs +// Released under the Apache 2.0 license -// Portions of this code copyright Grafana Labs and contributors -// and released under the Apache 2.0 license +// Modification notice: +// 1. All field names were changed from toml and snake case to yaml and kebab case, +// matching the Oragono project conventions +// 2. Two fields were added: `Autocreate` and `Timeout` + +// XXX: none of AttributeMap does anything in oragono, except MemberOf, +// which can be used to retrieve group memberships package ldap import ( - "fmt" - "strings" "time" ) -type LDAPConfig struct { +type ServerConfig struct { Enabled bool Autocreate bool @@ -22,7 +24,7 @@ type LDAPConfig struct { Timeout time.Duration UseSSL bool `yaml:"use-ssl"` StartTLS bool `yaml:"start-tls"` - SkipTLSVerify bool `yaml:"skip-tls-verify"` + SkipVerifySSL bool `yaml:"ssl-skip-verify"` RootCACert string `yaml:"root-ca-cert"` ClientCert string `yaml:"client-cert"` ClientKey string `yaml:"client-key"` @@ -35,29 +37,22 @@ type LDAPConfig struct { // user validation: require them to be in any one of these groups RequireGroups []string `yaml:"require-groups"` - // two ways of testing group membership: either via an attribute - // of the user's DN, typically named 'memberOf', but customizable: - MemberOfAttribute string `yaml:"member-of-attribute"` - // or by searching for groups that match the user's DN + // two ways of testing group membership: + // either by searching for groups that match the user's DN // and testing their names: GroupSearchFilter string `yaml:"group-search-filter"` GroupSearchFilterUserAttribute string `yaml:"group-search-filter-user-attribute"` GroupSearchBaseDNs []string `yaml:"group-search-base-dns"` + + // or by an attribute on the user's DN, typically named 'memberOf', but customizable: + Attr AttributeMap `yaml:"attributes"` } -// shouldAdminBind checks if we should use -// admin username & password for LDAP bind -func (config *LDAPConfig) shouldAdminBind() bool { - return config.BindPassword != "" -} - -// shouldSingleBind checks if we can use "single bind" approach -func (config *LDAPConfig) shouldSingleBind() bool { - return strings.Contains(config.BindDN, "%s") -} - -// singleBindDN combines the bind with the username -// in order to get the proper path -func (config *LDAPConfig) singleBindDN(username string) string { - return fmt.Sprintf(config.BindDN, username) +// AttributeMap is a struct representation for LDAP "attributes" setting +type AttributeMap struct { + Username string + Name string + Surname string + Email string + MemberOf string `yaml:"member-of"` } diff --git a/irc/ldap/grafana.go b/irc/ldap/grafana.go new file mode 100644 index 00000000..d846e671 --- /dev/null +++ b/irc/ldap/grafana.go @@ -0,0 +1,266 @@ +// Copyright 2014-2018 Grafana Labs +// Released under the Apache 2.0 license + +// Modification notice: these functions were altered by substituting +// `serverConn` for `Server`. + +package ldap + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io/ioutil" + "strings" + + ldap "github.com/go-ldap/ldap/v3" +) + +var ( + // ErrInvalidCredentials is returned if username and password do not match + ErrInvalidCredentials = errors.New("Invalid Username or Password") + + // ErrCouldNotFindUser is returned when username hasn't been found (not username+password) + ErrCouldNotFindUser = errors.New("Can't find user in LDAP") +) + +// shouldAdminBind checks if we should use +// admin username & password for LDAP bind +func (server *serverConn) shouldAdminBind() bool { + return server.Config.BindPassword != "" +} + +// singleBindDN combines the bind with the username +// in order to get the proper path +func (server *serverConn) singleBindDN(username string) string { + return fmt.Sprintf(server.Config.BindDN, username) +} + +// shouldSingleBind checks if we can use "single bind" approach +func (server *serverConn) shouldSingleBind() bool { + return strings.Contains(server.Config.BindDN, "%s") +} + +// Dial dials in the LDAP +// TODO: decrease cyclomatic complexity +func (server *serverConn) Dial() error { + var err error + var certPool *x509.CertPool + if server.Config.RootCACert != "" { + certPool = x509.NewCertPool() + for _, caCertFile := range strings.Split(server.Config.RootCACert, " ") { + pem, err := ioutil.ReadFile(caCertFile) + if err != nil { + return err + } + if !certPool.AppendCertsFromPEM(pem) { + return errors.New("Failed to append CA certificate " + caCertFile) + } + } + } + var clientCert tls.Certificate + if server.Config.ClientCert != "" && server.Config.ClientKey != "" { + clientCert, err = tls.LoadX509KeyPair(server.Config.ClientCert, server.Config.ClientKey) + if err != nil { + return err + } + } + for _, host := range strings.Split(server.Config.Host, " ") { + address := fmt.Sprintf("%s:%d", host, server.Config.Port) + if server.Config.UseSSL { + tlsCfg := &tls.Config{ + InsecureSkipVerify: server.Config.SkipVerifySSL, + ServerName: host, + RootCAs: certPool, + } + if len(clientCert.Certificate) > 0 { + tlsCfg.Certificates = append(tlsCfg.Certificates, clientCert) + } + if server.Config.StartTLS { + server.Connection, err = ldap.Dial("tcp", address) + if err == nil { + if err = server.Connection.StartTLS(tlsCfg); err == nil { + return nil + } + } + } else { + server.Connection, err = ldap.DialTLS("tcp", address, tlsCfg) + } + } else { + server.Connection, err = ldap.Dial("tcp", address) + } + + if err == nil { + return nil + } + } + return err +} + +// Close closes the LDAP connection +// Dial() sets the connection with the server for this Struct. Therefore, we require a +// call to Dial() before being able to execute this function. +func (server *serverConn) Close() { + server.Connection.Close() +} + +// userBind binds the user with the LDAP server +func (server *serverConn) userBind(path, password string) error { + err := server.Connection.Bind(path, password) + if err != nil { + if ldapErr, ok := err.(*ldap.Error); ok { + if ldapErr.ResultCode == 49 { + return ErrInvalidCredentials + } + } + return err + } + + return nil +} + +// users is helper method for the Users() +func (server *serverConn) users(logins []string) ( + []*ldap.Entry, + error, +) { + var result *ldap.SearchResult + var Config = server.Config + var err error + + for _, base := range Config.SearchBaseDNs { + result, err = server.Connection.Search( + server.getSearchRequest(base, logins), + ) + if err != nil { + return nil, err + } + + if len(result.Entries) > 0 { + break + } + } + + return result.Entries, nil +} + +// getSearchRequest returns LDAP search request for users +func (server *serverConn) getSearchRequest( + base string, + logins []string, +) *ldap.SearchRequest { + attributes := []string{} + + inputs := server.Config.Attr + attributes = appendIfNotEmpty( + attributes, + inputs.Username, + inputs.Surname, + inputs.Email, + inputs.Name, + inputs.MemberOf, + + // In case for the POSIX LDAP schema server + server.Config.GroupSearchFilterUserAttribute, + ) + + search := "" + for _, login := range logins { + query := strings.Replace( + server.Config.SearchFilter, + "%s", ldap.EscapeFilter(login), + -1, + ) + + search = search + query + } + + filter := fmt.Sprintf("(|%s)", search) + + return &ldap.SearchRequest{ + BaseDN: base, + Scope: ldap.ScopeWholeSubtree, + DerefAliases: ldap.NeverDerefAliases, + Attributes: attributes, + Filter: filter, + } +} + +// requestMemberOf use this function when POSIX LDAP +// schema does not support memberOf, so it manually search the groups +func (server *serverConn) requestMemberOf(entry *ldap.Entry) ([]string, error) { + var memberOf []string + var config = server.Config + + for _, groupSearchBase := range config.GroupSearchBaseDNs { + var filterReplace string + if config.GroupSearchFilterUserAttribute == "" { + filterReplace = getAttribute(config.Attr.Username, entry) + } else { + filterReplace = getAttribute( + config.GroupSearchFilterUserAttribute, + entry, + ) + } + + filter := strings.Replace( + config.GroupSearchFilter, "%s", + ldap.EscapeFilter(filterReplace), + -1, + ) + + server.log.Info("Searching for user's groups", "filter", filter) + + // support old way of reading settings + groupIDAttribute := config.Attr.MemberOf + // but prefer dn attribute if default settings are used + if groupIDAttribute == "" || groupIDAttribute == "memberOf" { + groupIDAttribute = "dn" + } + + groupSearchReq := ldap.SearchRequest{ + BaseDN: groupSearchBase, + Scope: ldap.ScopeWholeSubtree, + DerefAliases: ldap.NeverDerefAliases, + Attributes: []string{groupIDAttribute}, + Filter: filter, + } + + groupSearchResult, err := server.Connection.Search(&groupSearchReq) + if err != nil { + return nil, err + } + + if len(groupSearchResult.Entries) > 0 { + for _, group := range groupSearchResult.Entries { + + memberOf = append( + memberOf, + getAttribute(groupIDAttribute, group), + ) + } + break + } + } + + return memberOf, nil +} + +// getMemberOf finds memberOf property or request it +func (server *serverConn) getMemberOf(result *ldap.Entry) ( + []string, error, +) { + if server.Config.GroupSearchFilter == "" { + memberOf := getArrayAttribute(server.Config.Attr.MemberOf, result) + + return memberOf, nil + } + + memberOf, err := server.requestMemberOf(result) + if err != nil { + return nil, err + } + + return memberOf, nil +} diff --git a/irc/ldap/helpers.go b/irc/ldap/helpers.go index 12cee6ad..cadb822c 100644 --- a/irc/ldap/helpers.go +++ b/irc/ldap/helpers.go @@ -1,5 +1,5 @@ -// Copyright Grafana Labs and contributors -// and released under the Apache 2.0 license +// Copyright 2014-2018 Grafana Labs +// Released under the Apache 2.0 license package ldap @@ -49,3 +49,12 @@ func getAttribute(name string, entry *ldap.Entry) string { } return "" } + +func appendIfNotEmpty(slice []string, values ...string) []string { + for _, v := range values { + if v != "" { + slice = append(slice, v) + } + } + return slice +} diff --git a/irc/ldap/login.go b/irc/ldap/login.go index cc6938fc..a28bf8bd 100644 --- a/irc/ldap/login.go +++ b/irc/ldap/login.go @@ -30,12 +30,8 @@ package ldap import ( - "crypto/tls" - "crypto/x509" "errors" "fmt" - "io/ioutil" - "strings" ldap "github.com/go-ldap/ldap/v3" @@ -43,38 +39,48 @@ import ( ) var ( - ErrCouldNotFindUser = errors.New("No such user") ErrUserNotInRequiredGroup = errors.New("User is not a member of any required groups") - ErrInvalidCredentials = errors.New("Invalid credentials") ) -func CheckLDAPPassphrase(config LDAPConfig, accountName, passphrase string, log *logger.Manager) (err error) { +// equivalent of Grafana's `Server`, but unexported +type serverConn struct { + Config *ServerConfig + Connection *ldap.Conn + log *logger.Manager +} + +func CheckLDAPPassphrase(config ServerConfig, accountName, passphrase string, log *logger.Manager) (err error) { defer func() { if err != nil { log.Debug("ldap", "failed passphrase check", err.Error()) } }() - l, err := dial(&config) + server := serverConn{ + Config: &config, + log: log, + } + + err = server.Dial() if err != nil { return } - defer l.Close() + defer server.Close() - l.SetTimeout(config.Timeout) + server.Connection.SetTimeout(config.Timeout) passphraseChecked := false - if config.shouldSingleBind() { + if server.shouldSingleBind() { log.Debug("ldap", "attempting single bind to", accountName) - err = l.Bind(config.singleBindDN(accountName), passphrase) + err = server.userBind(server.singleBindDN(accountName), passphrase) passphraseChecked = (err == nil) - } else if config.shouldAdminBind() { + } else if server.shouldAdminBind() { log.Debug("ldap", "attempting admin bind to", config.BindDN) - err = l.Bind(config.BindDN, config.BindPassword) + err = server.userBind(config.BindDN, config.BindPassword) } else { log.Debug("ldap", "attempting unauthenticated bind") - err = l.UnauthenticatedBind(config.BindDN) + err = server.Connection.UnauthenticatedBind(config.BindDN) } if err != nil { @@ -85,7 +91,7 @@ func CheckLDAPPassphrase(config LDAPConfig, accountName, passphrase string, log return nil } - users, err := lookupUsers(l, &config, accountName) + users, err := server.users([]string{accountName}) if err != nil { log.Debug("ldap", "failed user lookup") return err @@ -99,225 +105,46 @@ func CheckLDAPPassphrase(config LDAPConfig, accountName, passphrase string, log log.Debug("ldap", "looked up user", user.DN) - err = validateGroupMembership(l, &config, user, log) + err = server.validateGroupMembership(user) if err != nil { return err } if !passphraseChecked { - // Authenticate user log.Debug("ldap", "rebinding", user.DN) - err = l.Bind(user.DN, passphrase) - if err != nil { - log.Debug("ldap", "failed rebind", err.Error()) - if ldapErr, ok := err.(*ldap.Error); ok { - if ldapErr.ResultCode == 49 { - return ErrInvalidCredentials - } - } - } - return err + err = server.userBind(user.DN, passphrase) } - return nil + return err } -func dial(config *LDAPConfig) (conn *ldap.Conn, err error) { - var certPool *x509.CertPool - if config.RootCACert != "" { - certPool = x509.NewCertPool() - for _, caCertFile := range strings.Split(config.RootCACert, " ") { - pem, err := ioutil.ReadFile(caCertFile) - if err != nil { - return nil, err - } - if !certPool.AppendCertsFromPEM(pem) { - return nil, errors.New("Failed to append CA certificate " + caCertFile) - } - } +func (server *serverConn) validateGroupMembership(user *ldap.Entry) (err error) { + if len(server.Config.RequireGroups) == 0 { + return } - var clientCert tls.Certificate - if config.ClientCert != "" && config.ClientKey != "" { - clientCert, err = tls.LoadX509KeyPair(config.ClientCert, config.ClientKey) - if err != nil { - return - } - } - for _, host := range strings.Split(config.Host, " ") { - address := fmt.Sprintf("%s:%d", host, config.Port) - if config.UseSSL { - tlsCfg := &tls.Config{ - InsecureSkipVerify: config.SkipTLSVerify, - ServerName: host, - RootCAs: certPool, - } - if len(clientCert.Certificate) > 0 { - tlsCfg.Certificates = append(tlsCfg.Certificates, clientCert) - } - if config.StartTLS { - conn, err = ldap.Dial("tcp", address) - if err == nil { - if err = conn.StartTLS(tlsCfg); err == nil { - return - } - } - } else { - conn, err = ldap.DialTLS("tcp", address, tlsCfg) - } - } else { - conn, err = ldap.Dial("tcp", address) - } - if err == nil { - return - } + var memberOf []string + memberOf, err = server.getMemberOf(user) + if err != nil { + server.log.Debug("ldap", "could not retrieve group memberships", err.Error()) + return } - return -} - -func validateGroupMembership(conn *ldap.Conn, config *LDAPConfig, user *ldap.Entry, log *logger.Manager) (err error) { - if len(config.RequireGroups) != 0 { - var memberOf []string - memberOf, err = getMemberOf(conn, config, user) - if err != nil { - log.Debug("ldap", "could not retrieve group memberships", err.Error()) - return - } - log.Debug("ldap", fmt.Sprintf("found group memberships: %v", memberOf)) - foundGroup := false - for _, inGroup := range memberOf { - for _, acceptableGroup := range config.RequireGroups { - if inGroup == acceptableGroup { - foundGroup = true - break - } - } - if foundGroup { + server.log.Debug("ldap", fmt.Sprintf("found group memberships: %v", memberOf)) + foundGroup := false + for _, inGroup := range memberOf { + for _, acceptableGroup := range server.Config.RequireGroups { + if inGroup == acceptableGroup { + foundGroup = true break } } - if !foundGroup { - return ErrUserNotInRequiredGroup - } - } - return nil -} - -func lookupUsers(conn *ldap.Conn, config *LDAPConfig, accountName string) (results []*ldap.Entry, err error) { - var result *ldap.SearchResult - - for _, base := range config.SearchBaseDNs { - result, err = conn.Search( - getSearchRequest(config, base, accountName), - ) - if err != nil { - return nil, err - } else if len(result.Entries) > 0 { - return result.Entries, nil - } - } - - return nil, nil -} - -// getSearchRequest returns LDAP search request for users -func getSearchRequest( - config *LDAPConfig, - base string, - accountName string, -) *ldap.SearchRequest { - - var attributes []string - if config.MemberOfAttribute != "" { - attributes = []string{config.MemberOfAttribute} - } - - query := strings.Replace( - config.SearchFilter, - "%s", ldap.EscapeFilter(accountName), - -1, - ) - - return &ldap.SearchRequest{ - BaseDN: base, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - Attributes: attributes, - Filter: query, - } -} - -// getMemberOf finds memberOf property or request it -func getMemberOf(conn *ldap.Conn, config *LDAPConfig, result *ldap.Entry) ( - []string, error, -) { - if config.GroupSearchFilter == "" { - memberOf := getArrayAttribute(config.MemberOfAttribute, result) - - return memberOf, nil - } - - memberOf, err := requestMemberOf(conn, config, result) - if err != nil { - return nil, err - } - - return memberOf, nil -} - -// requestMemberOf use this function when POSIX LDAP -// schema does not support memberOf, so it manually search the groups -func requestMemberOf(conn *ldap.Conn, config *LDAPConfig, entry *ldap.Entry) ([]string, error) { - var memberOf []string - - for _, groupSearchBase := range config.GroupSearchBaseDNs { - var filterReplace string - if config.GroupSearchFilterUserAttribute == "" { - filterReplace = "cn" - } else { - filterReplace = getAttribute( - config.GroupSearchFilterUserAttribute, - entry, - ) - } - - filter := strings.Replace( - config.GroupSearchFilter, "%s", - ldap.EscapeFilter(filterReplace), - -1, - ) - - // support old way of reading settings - groupIDAttribute := config.MemberOfAttribute - // but prefer dn attribute if default settings are used - if groupIDAttribute == "" || groupIDAttribute == "memberOf" { - groupIDAttribute = "dn" - } - - groupSearchReq := ldap.SearchRequest{ - BaseDN: groupSearchBase, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - Attributes: []string{groupIDAttribute}, - Filter: filter, - } - - groupSearchResult, err := conn.Search(&groupSearchReq) - if err != nil { - return nil, err - } - - if len(groupSearchResult.Entries) > 0 { - for _, group := range groupSearchResult.Entries { - - memberOf = append( - memberOf, - getAttribute(groupIDAttribute, group), - ) - } + if foundGroup { break } } - - return memberOf, nil + if foundGroup { + return nil + } else { + return ErrUserNotInRequiredGroup + } } diff --git a/oragono.yaml b/oragono.yaml index d1e661e8..9e2f3e97 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -387,6 +387,7 @@ accounts: # support for deferring password checking to an external LDAP server # you should probably ignore this section! consult the grafana docs for details: # https://grafana.com/docs/grafana/latest/auth/ldap/ + # you will probably want to set require-sasl and disable accounts.registration.enabled # ldap: # enabled: true # # should we automatically create users if their LDAP login succeeds? @@ -412,7 +413,8 @@ accounts: # # - "dc=example,dc=com" # # example of group membership testing via user attributes, as in AD # # or with OpenLDAP's "memberOf overlay" (overrides group-search-filter): - # #member-of-attribute: "memberOf" + # attributes: + # member-of: "memberOf" # channel options channels: From 306ca986a88e2d0d9aa4d6b967d0d9979d7081ed Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Tue, 11 Feb 2020 22:08:41 -0500 Subject: [PATCH 5/5] minor fixes --- irc/ldap/config.go | 6 +++++- irc/ldap/grafana.go | 7 ++++--- irc/ldap/login.go | 10 ++++++---- oragono.yaml | 5 ++++- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/irc/ldap/config.go b/irc/ldap/config.go index cfd981e1..623fdf09 100644 --- a/irc/ldap/config.go +++ b/irc/ldap/config.go @@ -4,7 +4,11 @@ // Modification notice: // 1. All field names were changed from toml and snake case to yaml and kebab case, // matching the Oragono project conventions -// 2. Two fields were added: `Autocreate` and `Timeout` +// 2. Four fields were added: +// 2.1 `Enabled` +// 2.2 `Autocreate` +// 2.3 `Timeout` +// 2.4 `RequireGroups` // XXX: none of AttributeMap does anything in oragono, except MemberOf, // which can be used to retrieve group memberships diff --git a/irc/ldap/grafana.go b/irc/ldap/grafana.go index d846e671..4cd83cdb 100644 --- a/irc/ldap/grafana.go +++ b/irc/ldap/grafana.go @@ -1,8 +1,9 @@ // Copyright 2014-2018 Grafana Labs // Released under the Apache 2.0 license -// Modification notice: these functions were altered by substituting -// `serverConn` for `Server`. +// Modification notice: +// 1. `serverConn` was substituted for `Server` as the type of the server object +// 2. Debug loglines were altered to work with Oragono's logging system package ldap @@ -210,7 +211,7 @@ func (server *serverConn) requestMemberOf(entry *ldap.Entry) ([]string, error) { -1, ) - server.log.Info("Searching for user's groups", "filter", filter) + server.logger.Debug("ldap", "Searching for groups with filter", filter) // support old way of reading settings groupIDAttribute := config.Attr.MemberOf diff --git a/irc/ldap/login.go b/irc/ldap/login.go index a28bf8bd..fb22c992 100644 --- a/irc/ldap/login.go +++ b/irc/ldap/login.go @@ -43,10 +43,12 @@ var ( ) // equivalent of Grafana's `Server`, but unexported +// also, `log` was renamed to `logger`, since the APIs are slightly different +// and this way the compiler will catch any unchanged references to Grafana's `Server.log` type serverConn struct { Config *ServerConfig Connection *ldap.Conn - log *logger.Manager + logger *logger.Manager } func CheckLDAPPassphrase(config ServerConfig, accountName, passphrase string, log *logger.Manager) (err error) { @@ -58,7 +60,7 @@ func CheckLDAPPassphrase(config ServerConfig, accountName, passphrase string, lo server := serverConn{ Config: &config, - log: log, + logger: log, } err = server.Dial() @@ -126,10 +128,10 @@ func (server *serverConn) validateGroupMembership(user *ldap.Entry) (err error) var memberOf []string memberOf, err = server.getMemberOf(user) if err != nil { - server.log.Debug("ldap", "could not retrieve group memberships", err.Error()) + server.logger.Debug("ldap", "could not retrieve group memberships", err.Error()) return } - server.log.Debug("ldap", fmt.Sprintf("found group memberships: %v", memberOf)) + server.logger.Debug("ldap", fmt.Sprintf("found group memberships: %v", memberOf)) foundGroup := false for _, inGroup := range memberOf { for _, acceptableGroup := range server.Config.RequireGroups { diff --git a/oragono.yaml b/oragono.yaml index 9e2f3e97..bbf19da1 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -392,6 +392,8 @@ accounts: # enabled: true # # should we automatically create users if their LDAP login succeeds? # autocreate: true + # # example configuration that works with Forum Systems's testing server: + # # https://www.forumsys.com/tutorials/integration-how-to/ldap/online-ldap-test-server/ # host: "ldap.forumsys.com" # port: 389 # timeout: 30s @@ -404,7 +406,8 @@ accounts: # #bind-dn: "cn=read-only-admin,dc=example,dc=com" # #bind-password: "password" # #search-filter: "(uid=%s)" - # # example of requiring that users be in a particular group: + # # example of requiring that users be in a particular group + # # (note that this is an OR over the listed groups, not an AND): # #require-groups: # # - "ou=mathematicians,dc=example,dc=com" # #group-search-filter-user-attribute: "dn"