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 67e3d0c5..0bf822c7 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -15,6 +15,7 @@ import ( "unicode" "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" @@ -446,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 @@ -500,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 { @@ -686,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, ",") } @@ -828,14 +846,34 @@ func (am *AccountManager) checkPassphrase(accountName, passphrase string) (accou return } -func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName string, passphrase string) error { - account, err := am.checkPassphrase(accountName, passphrase) - if err != nil { - return err +func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName string, passphrase string) (err error) { + var account ClientAccount + + defer func() { + if err == nil { + am.Login(client, account) + } + }() + + 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 + } } - am.Login(client, account) - return nil + account, err = am.checkPassphrase(accountName, passphrase) + return err } func (am *AccountManager) LoadAccount(accountName string) (result ClientAccount, err error) { diff --git a/irc/config.go b/irc/config.go index 361cd15f..1944f383 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,6 +69,7 @@ type AccountConfig struct { Exempted []string exemptedNets []net.IPNet } `yaml:"require-sasl"` + LDAP ldap.ServerConfig LoginThrottling struct { Enabled bool Duration time.Duration 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/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 new file mode 100644 index 00000000..623fdf09 --- /dev/null +++ b/irc/ldap/config.go @@ -0,0 +1,62 @@ +// Copyright 2014-2018 Grafana Labs +// 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. 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 + +package ldap + +import ( + "time" +) + +type ServerConfig struct { + Enabled bool + Autocreate bool + + Host string + Port int + Timeout time.Duration + UseSSL bool `yaml:"use-ssl"` + StartTLS bool `yaml:"start-tls"` + SkipVerifySSL bool `yaml:"ssl-skip-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 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"` +} + +// 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..4cd83cdb --- /dev/null +++ b/irc/ldap/grafana.go @@ -0,0 +1,267 @@ +// Copyright 2014-2018 Grafana Labs +// Released under the Apache 2.0 license + +// 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 + +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.logger.Debug("ldap", "Searching for groups with 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 new file mode 100644 index 00000000..cadb822c --- /dev/null +++ b/irc/ldap/helpers.go @@ -0,0 +1,60 @@ +// Copyright 2014-2018 Grafana Labs +// 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 "" +} + +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 new file mode 100644 index 00000000..fb22c992 --- /dev/null +++ b/irc/ldap/login.go @@ -0,0 +1,152 @@ +// 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 ( + "errors" + "fmt" + + ldap "github.com/go-ldap/ldap/v3" + + "github.com/oragono/oragono/irc/logger" +) + +var ( + ErrUserNotInRequiredGroup = errors.New("User is not a member of any required groups") +) + +// 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 + logger *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()) + } + }() + + server := serverConn{ + Config: &config, + logger: log, + } + + err = server.Dial() + if err != nil { + return + } + defer server.Close() + + server.Connection.SetTimeout(config.Timeout) + + passphraseChecked := false + + if server.shouldSingleBind() { + log.Debug("ldap", "attempting single bind to", accountName) + err = server.userBind(server.singleBindDN(accountName), passphrase) + passphraseChecked = (err == nil) + } else if server.shouldAdminBind() { + log.Debug("ldap", "attempting admin bind to", config.BindDN) + err = server.userBind(config.BindDN, config.BindPassword) + } else { + log.Debug("ldap", "attempting unauthenticated bind") + err = server.Connection.UnauthenticatedBind(config.BindDN) + } + + if err != nil { + return + } + + if passphraseChecked && len(config.RequireGroups) == 0 { + return nil + } + + users, err := server.users([]string{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 = server.validateGroupMembership(user) + if err != nil { + return err + } + + if !passphraseChecked { + log.Debug("ldap", "rebinding", user.DN) + err = server.userBind(user.DN, passphrase) + } + + return err +} + +func (server *serverConn) validateGroupMembership(user *ldap.Entry) (err error) { + if len(server.Config.RequireGroups) == 0 { + return + } + + var memberOf []string + memberOf, err = server.getMemberOf(user) + if err != nil { + server.logger.Debug("ldap", "could not retrieve group memberships", err.Error()) + return + } + server.logger.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 { + break + } + } + if foundGroup { + return nil + } else { + return ErrUserNotInRequiredGroup + } +} 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..bbf19da1 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -384,6 +384,41 @@ 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/ + # 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? + # 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 + # # 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 + # # (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" + # #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): + # attributes: + # member-of: "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