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