diff --git a/conf/defaults.ini b/conf/defaults.ini index afa742fc4fc..61bc6af6dd8 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -362,6 +362,7 @@ headers = enabled = false config_file = /etc/grafana/ldap.toml allow_sign_up = true +sync_cron = @hourly # LDAP backround sync (Enterprise only) sync_cron = @hourly diff --git a/go.mod b/go.mod index eeff9bec54d..fc72780af2f 100644 --- a/go.mod +++ b/go.mod @@ -53,6 +53,7 @@ require ( github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 github.com/prometheus/common v0.2.0 github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect + github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 github.com/sergi/go-diff v1.0.0 // indirect github.com/smartystreets/assertions v0.0.0-20190401211740-f487f9de1cd3 // indirect github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a diff --git a/go.sum b/go.sum index 9d2ab183563..55223ecbc74 100644 --- a/go.sum +++ b/go.sum @@ -167,6 +167,8 @@ github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a h1:9a8MnZMP0X2nL github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= +github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 h1:x7xEyJDP7Hv3LVgvWhzioQqbC/KtuUhTigKlH/8ehhE= +github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= diff --git a/pkg/extensions/main.go b/pkg/extensions/main.go index 057974c3fec..6ee742a4d8e 100644 --- a/pkg/extensions/main.go +++ b/pkg/extensions/main.go @@ -2,6 +2,7 @@ package extensions import ( _ "github.com/gobwas/glob" + _ "github.com/robfig/cron" _ "gopkg.in/square/go-jose.v2" ) diff --git a/pkg/login/auth.go b/pkg/login/auth.go index 301927b6ad3..56f614d92de 100644 --- a/pkg/login/auth.go +++ b/pkg/login/auth.go @@ -5,10 +5,12 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + LDAP "github.com/grafana/grafana/pkg/services/ldap" ) var ( ErrEmailNotAllowed = errors.New("Required email domain not fulfilled") + ErrNoLDAPServers = errors.New("No LDAP servers are configured") ErrInvalidCredentials = errors.New("Invalid Username or Password") ErrNoEmail = errors.New("Login provider didn't return an email address") ErrProviderDeniedRequest = errors.New("Login provider denied login request") @@ -21,7 +23,6 @@ var ( func Init() { bus.AddHandler("auth", AuthenticateUser) - loadLdapConfig() } func AuthenticateUser(query *m.LoginUserQuery) error { @@ -40,14 +41,14 @@ func AuthenticateUser(query *m.LoginUserQuery) error { ldapEnabled, ldapErr := loginUsingLdap(query) if ldapEnabled { - if ldapErr == nil || ldapErr != ErrInvalidCredentials { + if ldapErr == nil || ldapErr != LDAP.ErrInvalidCredentials { return ldapErr } err = ldapErr } - if err == ErrInvalidCredentials { + if err == ErrInvalidCredentials || err == LDAP.ErrInvalidCredentials { saveInvalidLoginAttempt(query) } diff --git a/pkg/login/auth_test.go b/pkg/login/auth_test.go index a4cd8284cdd..85ad3bc07dc 100644 --- a/pkg/login/auth_test.go +++ b/pkg/login/auth_test.go @@ -4,8 +4,10 @@ import ( "errors" "testing" - m "github.com/grafana/grafana/pkg/models" . "github.com/smartystreets/goconvey/convey" + + m "github.com/grafana/grafana/pkg/models" + LDAP "github.com/grafana/grafana/pkg/services/ldap" ) func TestAuthenticateUser(t *testing.T) { @@ -100,13 +102,13 @@ func TestAuthenticateUser(t *testing.T) { authScenario("When a non-existing grafana user authenticate and invalid ldap credentials", func(sc *authScenarioContext) { mockLoginAttemptValidation(nil, sc) mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) - mockLoginUsingLdap(true, ErrInvalidCredentials, sc) + mockLoginUsingLdap(true, LDAP.ErrInvalidCredentials, sc) mockSaveInvalidLoginAttempt(sc) err := AuthenticateUser(sc.loginUserQuery) Convey("it should result in", func() { - So(err, ShouldEqual, ErrInvalidCredentials) + So(err, ShouldEqual, LDAP.ErrInvalidCredentials) So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) So(sc.grafanaLoginWasCalled, ShouldBeTrue) So(sc.ldapLoginWasCalled, ShouldBeTrue) @@ -152,13 +154,13 @@ func TestAuthenticateUser(t *testing.T) { authScenario("When grafana user authenticate with invalid credentials and invalid ldap credentials", func(sc *authScenarioContext) { mockLoginAttemptValidation(nil, sc) mockLoginUsingGrafanaDB(ErrInvalidCredentials, sc) - mockLoginUsingLdap(true, ErrInvalidCredentials, sc) + mockLoginUsingLdap(true, LDAP.ErrInvalidCredentials, sc) mockSaveInvalidLoginAttempt(sc) err := AuthenticateUser(sc.loginUserQuery) Convey("it should result in", func() { - So(err, ShouldEqual, ErrInvalidCredentials) + So(err, ShouldEqual, LDAP.ErrInvalidCredentials) So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) So(sc.grafanaLoginWasCalled, ShouldBeTrue) So(sc.ldapLoginWasCalled, ShouldBeTrue) diff --git a/pkg/login/grafana_login_test.go b/pkg/login/grafana_login_test.go index 90422678fd2..2c189ae0072 100644 --- a/pkg/login/grafana_login_test.go +++ b/pkg/login/grafana_login_test.go @@ -3,9 +3,10 @@ package login import ( "testing" + . "github.com/smartystreets/goconvey/convey" + "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" - . "github.com/smartystreets/goconvey/convey" ) func TestGrafanaLogin(t *testing.T) { diff --git a/pkg/login/ldap.go b/pkg/login/ldap.go deleted file mode 100644 index 24ab6fdc0f8..00000000000 --- a/pkg/login/ldap.go +++ /dev/null @@ -1,430 +0,0 @@ -package login - -import ( - "crypto/tls" - "crypto/x509" - "errors" - "fmt" - "io/ioutil" - "strings" - - "github.com/davecgh/go-spew/spew" - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/log" - m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" - "gopkg.in/ldap.v3" -) - -type ILdapConn interface { - Bind(username, password string) error - UnauthenticatedBind(username string) error - Search(*ldap.SearchRequest) (*ldap.SearchResult, error) - StartTLS(*tls.Config) error - Close() -} - -type ILdapAuther interface { - Login(query *m.LoginUserQuery) error - SyncUser(query *m.LoginUserQuery) error - GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo) (*m.User, error) -} - -type ldapAuther struct { - server *LdapServerConf - conn ILdapConn - requireSecondBind bool - log log.Logger -} - -var NewLdapAuthenticator = func(server *LdapServerConf) ILdapAuther { - return &ldapAuther{server: server, log: log.New("ldap")} -} - -var ldapDial = func(network, addr string) (ILdapConn, error) { - return ldap.Dial(network, addr) -} - -func (a *ldapAuther) Dial() error { - var err error - var certPool *x509.CertPool - if a.server.RootCACert != "" { - certPool = x509.NewCertPool() - for _, caCertFile := range strings.Split(a.server.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 a.server.ClientCert != "" && a.server.ClientKey != "" { - clientCert, err = tls.LoadX509KeyPair(a.server.ClientCert, a.server.ClientKey) - if err != nil { - return err - } - } - for _, host := range strings.Split(a.server.Host, " ") { - address := fmt.Sprintf("%s:%d", host, a.server.Port) - if a.server.UseSSL { - tlsCfg := &tls.Config{ - InsecureSkipVerify: a.server.SkipVerifySSL, - ServerName: host, - RootCAs: certPool, - } - if len(clientCert.Certificate) > 0 { - tlsCfg.Certificates = append(tlsCfg.Certificates, clientCert) - } - if a.server.StartTLS { - a.conn, err = ldap.Dial("tcp", address) - if err == nil { - if err = a.conn.StartTLS(tlsCfg); err == nil { - return nil - } - } - } else { - a.conn, err = ldap.DialTLS("tcp", address, tlsCfg) - } - } else { - a.conn, err = ldapDial("tcp", address) - } - - if err == nil { - return nil - } - } - return err -} - -func (a *ldapAuther) Login(query *m.LoginUserQuery) error { - // connect to ldap server - if err := a.Dial(); err != nil { - return err - } - defer a.conn.Close() - - // perform initial authentication - if err := a.initialBind(query.Username, query.Password); err != nil { - return err - } - - // find user entry & attributes - ldapUser, err := a.searchForUser(query.Username) - if err != nil { - return err - } - - a.log.Debug("Ldap User found", "info", spew.Sdump(ldapUser)) - - // check if a second user bind is needed - if a.requireSecondBind { - err = a.secondBind(ldapUser, query.Password) - if err != nil { - return err - } - } - - grafanaUser, err := a.GetGrafanaUserFor(query.ReqContext, ldapUser) - if err != nil { - return err - } - - query.User = grafanaUser - return nil -} - -func (a *ldapAuther) SyncUser(query *m.LoginUserQuery) error { - // connect to ldap server - err := a.Dial() - if err != nil { - return err - } - defer a.conn.Close() - - err = a.serverBind() - if err != nil { - return err - } - - // find user entry & attributes - ldapUser, err := a.searchForUser(query.Username) - if err != nil { - a.log.Error("Failed searching for user in ldap", "error", err) - return err - } - - a.log.Debug("Ldap User found", "info", spew.Sdump(ldapUser)) - - grafanaUser, err := a.GetGrafanaUserFor(query.ReqContext, ldapUser) - if err != nil { - return err - } - - query.User = grafanaUser - return nil -} - -func (a *ldapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo) (*m.User, error) { - extUser := &m.ExternalUserInfo{ - AuthModule: "ldap", - AuthId: ldapUser.DN, - Name: fmt.Sprintf("%s %s", ldapUser.FirstName, ldapUser.LastName), - Login: ldapUser.Username, - Email: ldapUser.Email, - Groups: ldapUser.MemberOf, - OrgRoles: map[int64]m.RoleType{}, - } - - for _, group := range a.server.LdapGroups { - // only use the first match for each org - if extUser.OrgRoles[group.OrgId] != "" { - continue - } - - if ldapUser.isMemberOf(group.GroupDN) { - extUser.OrgRoles[group.OrgId] = group.OrgRole - if extUser.IsGrafanaAdmin == nil || !*extUser.IsGrafanaAdmin { - extUser.IsGrafanaAdmin = group.IsGrafanaAdmin - } - } - } - - // validate that the user has access - // if there are no ldap group mappings access is true - // otherwise a single group must match - if len(a.server.LdapGroups) > 0 && len(extUser.OrgRoles) < 1 { - a.log.Info( - "Ldap Auth: user does not belong in any of the specified ldap groups", - "username", ldapUser.Username, - "groups", ldapUser.MemberOf) - return nil, ErrInvalidCredentials - } - - // add/update user in grafana - upsertUserCmd := &m.UpsertUserCommand{ - ReqContext: ctx, - ExternalUser: extUser, - SignupAllowed: setting.LdapAllowSignup, - } - - err := bus.Dispatch(upsertUserCmd) - if err != nil { - return nil, err - } - - return upsertUserCmd.Result, nil -} - -func (a *ldapAuther) serverBind() error { - bindFn := func() error { - return a.conn.Bind(a.server.BindDN, a.server.BindPassword) - } - - if a.server.BindPassword == "" { - bindFn = func() error { - return a.conn.UnauthenticatedBind(a.server.BindDN) - } - } - - // bind_dn and bind_password to bind - if err := bindFn(); err != nil { - a.log.Info("LDAP initial bind failed, %v", err) - - if ldapErr, ok := err.(*ldap.Error); ok { - if ldapErr.ResultCode == 49 { - return ErrInvalidCredentials - } - } - return err - } - - return nil -} - -func (a *ldapAuther) secondBind(ldapUser *LdapUserInfo, userPassword string) error { - if err := a.conn.Bind(ldapUser.DN, userPassword); err != nil { - a.log.Info("Second bind failed", "error", err) - - if ldapErr, ok := err.(*ldap.Error); ok { - if ldapErr.ResultCode == 49 { - return ErrInvalidCredentials - } - } - return err - } - - return nil -} - -func (a *ldapAuther) initialBind(username, userPassword string) error { - if a.server.BindPassword != "" || a.server.BindDN == "" { - userPassword = a.server.BindPassword - a.requireSecondBind = true - } - - bindPath := a.server.BindDN - if strings.Contains(bindPath, "%s") { - bindPath = fmt.Sprintf(a.server.BindDN, username) - } - - bindFn := func() error { - return a.conn.Bind(bindPath, userPassword) - } - - if userPassword == "" { - bindFn = func() error { - return a.conn.UnauthenticatedBind(bindPath) - } - } - - if err := bindFn(); err != nil { - a.log.Info("Initial bind failed", "error", err) - - if ldapErr, ok := err.(*ldap.Error); ok { - if ldapErr.ResultCode == 49 { - return ErrInvalidCredentials - } - } - return err - } - - return nil -} - -func appendIfNotEmpty(slice []string, values ...string) []string { - for _, v := range values { - if v != "" { - slice = append(slice, v) - } - } - return slice -} - -func (a *ldapAuther) searchForUser(username string) (*LdapUserInfo, error) { - var searchResult *ldap.SearchResult - var err error - - for _, searchBase := range a.server.SearchBaseDNs { - attributes := make([]string, 0) - inputs := a.server.Attr - attributes = appendIfNotEmpty(attributes, - inputs.Username, - inputs.Surname, - inputs.Email, - inputs.Name, - inputs.MemberOf) - - searchReq := ldap.SearchRequest{ - BaseDN: searchBase, - Scope: ldap.ScopeWholeSubtree, - DerefAliases: ldap.NeverDerefAliases, - Attributes: attributes, - Filter: strings.Replace(a.server.SearchFilter, "%s", ldap.EscapeFilter(username), -1), - } - - a.log.Debug("Ldap Search For User Request", "info", spew.Sdump(searchReq)) - - searchResult, err = a.conn.Search(&searchReq) - if err != nil { - return nil, err - } - - if len(searchResult.Entries) > 0 { - break - } - } - - if len(searchResult.Entries) == 0 { - return nil, ErrInvalidCredentials - } - - if len(searchResult.Entries) > 1 { - return nil, errors.New("Ldap search matched more than one entry, please review your filter setting") - } - - var memberOf []string - if a.server.GroupSearchFilter == "" { - memberOf = getLdapAttrArray(a.server.Attr.MemberOf, searchResult) - } else { - // If we are using a POSIX LDAP schema it won't support memberOf, so we manually search the groups - var groupSearchResult *ldap.SearchResult - for _, groupSearchBase := range a.server.GroupSearchBaseDNs { - var filter_replace string - if a.server.GroupSearchFilterUserAttribute == "" { - filter_replace = getLdapAttr(a.server.Attr.Username, searchResult) - } else { - filter_replace = getLdapAttr(a.server.GroupSearchFilterUserAttribute, searchResult) - } - - filter := strings.Replace(a.server.GroupSearchFilter, "%s", ldap.EscapeFilter(filter_replace), -1) - - a.log.Info("Searching for user's groups", "filter", filter) - - // support old way of reading settings - groupIdAttribute := a.server.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 = a.conn.Search(&groupSearchReq) - if err != nil { - return nil, err - } - - if len(groupSearchResult.Entries) > 0 { - for i := range groupSearchResult.Entries { - memberOf = append(memberOf, getLdapAttrN(groupIdAttribute, groupSearchResult, i)) - } - break - } - } - } - - return &LdapUserInfo{ - DN: searchResult.Entries[0].DN, - LastName: getLdapAttr(a.server.Attr.Surname, searchResult), - FirstName: getLdapAttr(a.server.Attr.Name, searchResult), - Username: getLdapAttr(a.server.Attr.Username, searchResult), - Email: getLdapAttr(a.server.Attr.Email, searchResult), - MemberOf: memberOf, - }, nil -} - -func getLdapAttrN(name string, result *ldap.SearchResult, n int) string { - if strings.ToLower(name) == "dn" { - return result.Entries[n].DN - } - for _, attr := range result.Entries[n].Attributes { - if attr.Name == name { - if len(attr.Values) > 0 { - return attr.Values[0] - } - } - } - return "" -} - -func getLdapAttr(name string, result *ldap.SearchResult) string { - return getLdapAttrN(name, result, 0) -} - -func getLdapAttrArray(name string, result *ldap.SearchResult) []string { - for _, attr := range result.Entries[0].Attributes { - if attr.Name == name { - return attr.Values - } - } - return []string{} -} diff --git a/pkg/login/ldap_login.go b/pkg/login/ldap_login.go index 5974e19d691..b8811158200 100644 --- a/pkg/login/ldap_login.go +++ b/pkg/login/ldap_login.go @@ -1,22 +1,34 @@ package login import ( - m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/models" + LDAP "github.com/grafana/grafana/pkg/services/ldap" ) -var loginUsingLdap = func(query *m.LoginUserQuery) (bool, error) { - if !setting.LdapEnabled { +var newLDAP = LDAP.New +var readLDAPConfig = LDAP.ReadConfig +var isLDAPEnabled = LDAP.IsEnabled + +var loginUsingLdap = func(query *models.LoginUserQuery) (bool, error) { + enabled := isLDAPEnabled() + + if !enabled { return false, nil } - for _, server := range LdapCfg.Servers { - author := NewLdapAuthenticator(server) - err := author.Login(query) - if err == nil || err != ErrInvalidCredentials { + config := readLDAPConfig() + if len(config.Servers) == 0 { + return true, ErrNoLDAPServers + } + + for _, server := range config.Servers { + auth := newLDAP(server) + + err := auth.Login(query) + if err == nil || err != LDAP.ErrInvalidCredentials { return true, err } } - return true, ErrInvalidCredentials + return true, LDAP.ErrInvalidCredentials } diff --git a/pkg/login/ldap_login_test.go b/pkg/login/ldap_login_test.go index 6067a063795..c6bd30834a9 100644 --- a/pkg/login/ldap_login_test.go +++ b/pkg/login/ldap_login_test.go @@ -1,71 +1,41 @@ package login import ( + "errors" "testing" - m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" + + m "github.com/grafana/grafana/pkg/models" + LDAP "github.com/grafana/grafana/pkg/services/ldap" + "github.com/grafana/grafana/pkg/setting" ) +var errTest = errors.New("Test error") + func TestLdapLogin(t *testing.T) { Convey("Login using ldap", t, func() { - Convey("Given ldap enabled and a server configured", func() { - setting.LdapEnabled = true - LdapCfg.Servers = append(LdapCfg.Servers, - &LdapServerConf{ - Host: "", - }) - - ldapLoginScenario("When login with invalid credentials", func(sc *ldapLoginScenarioContext) { - sc.withLoginResult(false) - enabled, err := loginUsingLdap(sc.loginUserQuery) - - Convey("it should return true", func() { - So(enabled, ShouldBeTrue) - }) - - Convey("it should return invalid credentials error", func() { - So(err, ShouldEqual, ErrInvalidCredentials) - }) - - Convey("it should call ldap login", func() { - So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeTrue) - }) - }) - - ldapLoginScenario("When login with valid credentials", func(sc *ldapLoginScenarioContext) { - sc.withLoginResult(true) - enabled, err := loginUsingLdap(sc.loginUserQuery) - - Convey("it should return true", func() { - So(enabled, ShouldBeTrue) - }) - - Convey("it should not return error", func() { - So(err, ShouldBeNil) - }) - - Convey("it should call ldap login", func() { - So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeTrue) - }) - }) - }) - Convey("Given ldap enabled and no server configured", func() { setting.LdapEnabled = true - LdapCfg.Servers = make([]*LdapServerConf, 0) ldapLoginScenario("When login", func(sc *ldapLoginScenarioContext) { - sc.withLoginResult(true) + sc.withLoginResult(false) + readLDAPConfig = func() *LDAP.Config { + config := &LDAP.Config{ + Servers: []*LDAP.ServerConfig{}, + } + + return config + } + enabled, err := loginUsingLdap(sc.loginUserQuery) Convey("it should return true", func() { So(enabled, ShouldBeTrue) }) - Convey("it should return invalid credentials error", func() { - So(err, ShouldEqual, ErrInvalidCredentials) + Convey("it should return no LDAP servers error", func() { + So(err, ShouldEqual, ErrNoLDAPServers) }) Convey("it should not call ldap login", func() { @@ -100,51 +70,55 @@ func TestLdapLogin(t *testing.T) { }) } -func mockLdapAuthenticator(valid bool) *mockLdapAuther { - mock := &mockLdapAuther{ +func mockLdapAuthenticator(valid bool) *mockAuth { + mock := &mockAuth{ validLogin: valid, } - NewLdapAuthenticator = func(server *LdapServerConf) ILdapAuther { + newLDAP = func(server *LDAP.ServerConfig) LDAP.IAuth { return mock } return mock } -type mockLdapAuther struct { +type mockAuth struct { validLogin bool loginCalled bool } -func (a *mockLdapAuther) Login(query *m.LoginUserQuery) error { - a.loginCalled = true +func (auth *mockAuth) Login(query *m.LoginUserQuery) error { + auth.loginCalled = true - if !a.validLogin { - return ErrInvalidCredentials + if !auth.validLogin { + return errTest } return nil } -func (a *mockLdapAuther) SyncUser(query *m.LoginUserQuery) error { +func (auth *mockAuth) Users() ([]*LDAP.UserInfo, error) { + return nil, nil +} + +func (auth *mockAuth) SyncUser(query *m.LoginUserQuery) error { return nil } -func (a *mockLdapAuther) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LdapUserInfo) (*m.User, error) { +func (auth *mockAuth) GetGrafanaUserFor(ctx *m.ReqContext, ldapUser *LDAP.UserInfo) (*m.User, error) { return nil, nil } type ldapLoginScenarioContext struct { loginUserQuery *m.LoginUserQuery - ldapAuthenticatorMock *mockLdapAuther + ldapAuthenticatorMock *mockAuth } type ldapLoginScenarioFunc func(c *ldapLoginScenarioContext) func ldapLoginScenario(desc string, fn ldapLoginScenarioFunc) { Convey(desc, func() { - origNewLdapAuthenticator := NewLdapAuthenticator + mock := &mockAuth{} sc := &ldapLoginScenarioContext{ loginUserQuery: &m.LoginUserQuery{ @@ -152,11 +126,28 @@ func ldapLoginScenario(desc string, fn ldapLoginScenarioFunc) { Password: "pwd", IpAddress: "192.168.1.1:56433", }, - ldapAuthenticatorMock: &mockLdapAuther{}, + ldapAuthenticatorMock: mock, + } + + readLDAPConfig = func() *LDAP.Config { + config := &LDAP.Config{ + Servers: []*LDAP.ServerConfig{ + { + Host: "", + }, + }, + } + + return config + } + + newLDAP = func(server *LDAP.ServerConfig) LDAP.IAuth { + return mock } defer func() { - NewLdapAuthenticator = origNewLdapAuthenticator + newLDAP = LDAP.New + readLDAPConfig = LDAP.ReadConfig }() fn(sc) diff --git a/pkg/login/ldap_settings.go b/pkg/login/ldap_settings.go deleted file mode 100644 index 40791a509db..00000000000 --- a/pkg/login/ldap_settings.go +++ /dev/null @@ -1,104 +0,0 @@ -package login - -import ( - "fmt" - "os" - - "github.com/BurntSushi/toml" - "github.com/grafana/grafana/pkg/log" - m "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" -) - -type LdapConfig struct { - Servers []*LdapServerConf `toml:"servers"` -} - -type LdapServerConf struct { - Host string `toml:"host"` - Port int `toml:"port"` - UseSSL bool `toml:"use_ssl"` - StartTLS bool `toml:"start_tls"` - SkipVerifySSL bool `toml:"ssl_skip_verify"` - RootCACert string `toml:"root_ca_cert"` - ClientCert string `toml:"client_cert"` - ClientKey string `toml:"client_key"` - BindDN string `toml:"bind_dn"` - BindPassword string `toml:"bind_password"` - Attr LdapAttributeMap `toml:"attributes"` - - SearchFilter string `toml:"search_filter"` - SearchBaseDNs []string `toml:"search_base_dns"` - - GroupSearchFilter string `toml:"group_search_filter"` - GroupSearchFilterUserAttribute string `toml:"group_search_filter_user_attribute"` - GroupSearchBaseDNs []string `toml:"group_search_base_dns"` - - LdapGroups []*LdapGroupToOrgRole `toml:"group_mappings"` -} - -type LdapAttributeMap struct { - Username string `toml:"username"` - Name string `toml:"name"` - Surname string `toml:"surname"` - Email string `toml:"email"` - MemberOf string `toml:"member_of"` -} - -type LdapGroupToOrgRole struct { - GroupDN string `toml:"group_dn"` - OrgId int64 `toml:"org_id"` - IsGrafanaAdmin *bool `toml:"grafana_admin"` // This is a pointer to know if it was set or not (for backwards compatibility) - OrgRole m.RoleType `toml:"org_role"` -} - -var LdapCfg LdapConfig -var ldapLogger log.Logger = log.New("ldap") - -func loadLdapConfig() { - if !setting.LdapEnabled { - return - } - - ldapLogger.Info("Ldap enabled, reading config file", "file", setting.LdapConfigFile) - - _, err := toml.DecodeFile(setting.LdapConfigFile, &LdapCfg) - if err != nil { - ldapLogger.Crit("Failed to load ldap config file", "error", err) - os.Exit(1) - } - - if len(LdapCfg.Servers) == 0 { - ldapLogger.Crit("ldap enabled but no ldap servers defined in config file") - os.Exit(1) - } - - // set default org id - for _, server := range LdapCfg.Servers { - assertNotEmptyCfg(server.SearchFilter, "search_filter") - assertNotEmptyCfg(server.SearchBaseDNs, "search_base_dns") - - for _, groupMap := range server.LdapGroups { - if groupMap.OrgId == 0 { - groupMap.OrgId = 1 - } - } - } -} - -func assertNotEmptyCfg(val interface{}, propName string) { - switch v := val.(type) { - case string: - if v == "" { - ldapLogger.Crit("LDAP config file is missing option", "option", propName) - os.Exit(1) - } - case []string: - if len(v) == 0 { - ldapLogger.Crit("LDAP config file is missing option", "option", propName) - os.Exit(1) - } - default: - fmt.Println("unknown") - } -} diff --git a/pkg/middleware/auth_proxy/auth_proxy.go b/pkg/middleware/auth_proxy/auth_proxy.go index 7b9ff8abba9..7eee806c082 100644 --- a/pkg/middleware/auth_proxy/auth_proxy.go +++ b/pkg/middleware/auth_proxy/auth_proxy.go @@ -10,8 +10,8 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/remotecache" - "github.com/grafana/grafana/pkg/login" - models "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/ldap" "github.com/grafana/grafana/pkg/setting" ) @@ -21,6 +21,11 @@ const ( CachePrefix = "auth-proxy-sync-ttl:%s" ) +var ( + readLDAPConfig = ldap.ReadConfig + isLDAPEnabled = ldap.IsEnabled +) + // AuthProxy struct type AuthProxy struct { store *remotecache.RemoteCache @@ -28,14 +33,13 @@ type AuthProxy struct { orgID int64 header string - LDAP func(server *login.LdapServerConf) login.ILdapAuther + LDAP func(server *ldap.ServerConfig) ldap.IAuth enabled bool whitelistIP string headerType string headers map[string]string cacheTTL int - ldapEnabled bool } // Error auth proxy specific error @@ -74,14 +78,13 @@ func New(options *Options) *AuthProxy { orgID: options.OrgID, header: header, - LDAP: login.NewLdapAuthenticator, + LDAP: ldap.New, enabled: setting.AuthProxyEnabled, headerType: setting.AuthProxyHeaderProperty, headers: setting.AuthProxyHeaders, whitelistIP: setting.AuthProxyWhitelist, cacheTTL: setting.AuthProxyLdapSyncTtl, - ldapEnabled: setting.LdapEnabled, } } @@ -167,11 +170,14 @@ func (auth *AuthProxy) GetUserID() (int64, *Error) { return id, nil } - if auth.ldapEnabled { + if isLDAPEnabled() { id, err := auth.GetUserIDViaLDAP() - if err == login.ErrInvalidCredentials { - return 0, newError("Proxy authentication required", login.ErrInvalidCredentials) + if err == ldap.ErrInvalidCredentials { + return 0, newError( + "Proxy authentication required", + ldap.ErrInvalidCredentials, + ) } if err != nil { @@ -183,7 +189,10 @@ func (auth *AuthProxy) GetUserID() (int64, *Error) { id, err := auth.GetUserIDViaHeader() if err != nil { - return 0, newError("Failed to login as user specified in auth proxy header", err) + return 0, newError( + "Failed to login as user specified in auth proxy header", + err, + ) } return id, nil @@ -210,12 +219,12 @@ func (auth *AuthProxy) GetUserIDViaLDAP() (int64, *Error) { Username: auth.header, } - ldapCfg := login.LdapCfg - if len(ldapCfg.Servers) < 1 { + config := readLDAPConfig() + if len(config.Servers) == 0 { return 0, newError("No LDAP servers available", nil) } - for _, server := range ldapCfg.Servers { + for _, server := range config.Servers { author := auth.LDAP(server) if err := author.SyncUser(query); err != nil { return 0, newError(err.Error(), nil) diff --git a/pkg/middleware/auth_proxy/auth_proxy_test.go b/pkg/middleware/auth_proxy/auth_proxy_test.go index 47ab75744d6..849c156ac98 100644 --- a/pkg/middleware/auth_proxy/auth_proxy_test.go +++ b/pkg/middleware/auth_proxy/auth_proxy_test.go @@ -5,16 +5,17 @@ import ( "net/http" "testing" - "github.com/grafana/grafana/pkg/infra/remotecache" - "github.com/grafana/grafana/pkg/login" - models "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" "gopkg.in/macaron.v1" + + "github.com/grafana/grafana/pkg/infra/remotecache" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/ldap" + "github.com/grafana/grafana/pkg/setting" ) type TestLDAP struct { - login.ILdapAuther + ldap.Auth ID int64 syncCalled bool } @@ -62,13 +63,23 @@ func TestMiddlewareContext(t *testing.T) { Convey("LDAP", func() { Convey("gets data from the LDAP", func() { - login.LdapCfg = login.LdapConfig{ - Servers: []*login.LdapServerConf{ - {}, - }, + isLDAPEnabled = func() bool { + return true } - setting.LdapEnabled = true + readLDAPConfig = func() *ldap.Config { + config := &ldap.Config{ + Servers: []*ldap.ServerConfig{ + {}, + }, + } + return config + } + + defer func() { + isLDAPEnabled = ldap.IsEnabled + readLDAPConfig = ldap.ReadConfig + }() store := remotecache.NewFakeStore(t) @@ -82,7 +93,7 @@ func TestMiddlewareContext(t *testing.T) { ID: 42, } - auth.LDAP = func(server *login.LdapServerConf) login.ILdapAuther { + auth.LDAP = func(server *ldap.ServerConfig) ldap.IAuth { return stub } @@ -94,7 +105,21 @@ func TestMiddlewareContext(t *testing.T) { }) Convey("gets nice error if ldap is enabled but not configured", func() { - setting.LdapEnabled = false + isLDAPEnabled = func() bool { + return true + } + + readLDAPConfig = func() *ldap.Config { + config := &ldap.Config{ + Servers: []*ldap.ServerConfig{}, + } + return config + } + + defer func() { + isLDAPEnabled = ldap.IsEnabled + readLDAPConfig = ldap.ReadConfig + }() store := remotecache.NewFakeStore(t) @@ -108,13 +133,14 @@ func TestMiddlewareContext(t *testing.T) { ID: 42, } - auth.LDAP = func(server *login.LdapServerConf) login.ILdapAuther { + auth.LDAP = func(server *ldap.ServerConfig) ldap.IAuth { return stub } id, err := auth.GetUserID() So(err, ShouldNotBeNil) + So(err.Error(), ShouldContainSubstring, "Failed to sync user") So(id, ShouldNotEqual, 42) So(stub.syncCalled, ShouldEqual, false) }) diff --git a/pkg/services/ldap/hooks.go b/pkg/services/ldap/hooks.go new file mode 100644 index 00000000000..ece98e5e73b --- /dev/null +++ b/pkg/services/ldap/hooks.go @@ -0,0 +1,5 @@ +package ldap + +var ( + hookDial func(*Auth) error +) diff --git a/pkg/services/ldap/ldap.go b/pkg/services/ldap/ldap.go new file mode 100644 index 00000000000..5ff74bd7a3e --- /dev/null +++ b/pkg/services/ldap/ldap.go @@ -0,0 +1,559 @@ +package ldap + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io/ioutil" + "strings" + + "github.com/davecgh/go-spew/spew" + LDAP "gopkg.in/ldap.v3" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/log" + models "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" +) + +// IConnection is interface for LDAP connection manipulation +type IConnection interface { + Bind(username, password string) error + UnauthenticatedBind(username string) error + Search(*LDAP.SearchRequest) (*LDAP.SearchResult, error) + StartTLS(*tls.Config) error + Close() +} + +// IAuth is interface for LDAP authorization +type IAuth interface { + Login(query *models.LoginUserQuery) error + SyncUser(query *models.LoginUserQuery) error + GetGrafanaUserFor( + ctx *models.ReqContext, + user *UserInfo, + ) (*models.User, error) + Users() ([]*UserInfo, error) +} + +// Auth is basic struct of LDAP authorization +type Auth struct { + server *ServerConfig + conn IConnection + requireSecondBind bool + log log.Logger +} + +var ( + + // ErrInvalidCredentials is returned if username and password do not match + ErrInvalidCredentials = errors.New("Invalid Username or Password") +) + +var dial = func(network, addr string) (IConnection, error) { + return LDAP.Dial(network, addr) +} + +// New creates the new LDAP auth +func New(server *ServerConfig) IAuth { + return &Auth{ + server: server, + log: log.New("ldap"), + } +} + +// Dial dials in the LDAP +func (auth *Auth) Dial() error { + if hookDial != nil { + return hookDial(auth) + } + + var err error + var certPool *x509.CertPool + if auth.server.RootCACert != "" { + certPool = x509.NewCertPool() + for _, caCertFile := range strings.Split(auth.server.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 auth.server.ClientCert != "" && auth.server.ClientKey != "" { + clientCert, err = tls.LoadX509KeyPair(auth.server.ClientCert, auth.server.ClientKey) + if err != nil { + return err + } + } + for _, host := range strings.Split(auth.server.Host, " ") { + address := fmt.Sprintf("%s:%d", host, auth.server.Port) + if auth.server.UseSSL { + tlsCfg := &tls.Config{ + InsecureSkipVerify: auth.server.SkipVerifySSL, + ServerName: host, + RootCAs: certPool, + } + if len(clientCert.Certificate) > 0 { + tlsCfg.Certificates = append(tlsCfg.Certificates, clientCert) + } + if auth.server.StartTLS { + auth.conn, err = dial("tcp", address) + if err == nil { + if err = auth.conn.StartTLS(tlsCfg); err == nil { + return nil + } + } + } else { + auth.conn, err = LDAP.DialTLS("tcp", address, tlsCfg) + } + } else { + auth.conn, err = dial("tcp", address) + } + + if err == nil { + return nil + } + } + return err +} + +// Login logs in the user +func (auth *Auth) Login(query *models.LoginUserQuery) error { + // connect to ldap server + if err := auth.Dial(); err != nil { + return err + } + defer auth.conn.Close() + + // perform initial authentication + if err := auth.initialBind(query.Username, query.Password); err != nil { + return err + } + + // find user entry & attributes + user, err := auth.searchForUser(query.Username) + if err != nil { + return err + } + + auth.log.Debug("Ldap User found", "info", spew.Sdump(user)) + + // check if a second user bind is needed + if auth.requireSecondBind { + err = auth.secondBind(user, query.Password) + if err != nil { + return err + } + } + + grafanaUser, err := auth.GetGrafanaUserFor(query.ReqContext, user) + if err != nil { + return err + } + + query.User = grafanaUser + return nil +} + +// SyncUser syncs user with Grafana +func (auth *Auth) SyncUser(query *models.LoginUserQuery) error { + // connect to ldap server + err := auth.Dial() + if err != nil { + return err + } + defer auth.conn.Close() + + err = auth.serverBind() + if err != nil { + return err + } + + // find user entry & attributes + user, err := auth.searchForUser(query.Username) + if err != nil { + auth.log.Error("Failed searching for user in ldap", "error", err) + return err + } + + auth.log.Debug("Ldap User found", "info", spew.Sdump(user)) + + grafanaUser, err := auth.GetGrafanaUserFor(query.ReqContext, user) + if err != nil { + return err + } + + query.User = grafanaUser + return nil +} + +func (auth *Auth) GetGrafanaUserFor( + ctx *models.ReqContext, + user *UserInfo, +) (*models.User, error) { + extUser := &models.ExternalUserInfo{ + AuthModule: "ldap", + AuthId: user.DN, + Name: fmt.Sprintf("%s %s", user.FirstName, user.LastName), + Login: user.Username, + Email: user.Email, + Groups: user.MemberOf, + OrgRoles: map[int64]models.RoleType{}, + } + + for _, group := range auth.server.Groups { + // only use the first match for each org + if extUser.OrgRoles[group.OrgId] != "" { + continue + } + + if user.isMemberOf(group.GroupDN) { + extUser.OrgRoles[group.OrgId] = group.OrgRole + if extUser.IsGrafanaAdmin == nil || !*extUser.IsGrafanaAdmin { + extUser.IsGrafanaAdmin = group.IsGrafanaAdmin + } + } + } + + // validate that the user has access + // if there are no ldap group mappings access is true + // otherwise a single group must match + if len(auth.server.Groups) > 0 && len(extUser.OrgRoles) < 1 { + auth.log.Info( + "Ldap Auth: user does not belong in any of the specified ldap groups", + "username", user.Username, + "groups", user.MemberOf, + ) + return nil, ErrInvalidCredentials + } + + // add/update user in grafana + upsertUserCmd := &models.UpsertUserCommand{ + ReqContext: ctx, + ExternalUser: extUser, + SignupAllowed: setting.LdapAllowSignup, + } + + err := bus.Dispatch(upsertUserCmd) + if err != nil { + return nil, err + } + + return upsertUserCmd.Result, nil +} + +func (auth *Auth) serverBind() error { + bindFn := func() error { + return auth.conn.Bind(auth.server.BindDN, auth.server.BindPassword) + } + + if auth.server.BindPassword == "" { + bindFn = func() error { + return auth.conn.UnauthenticatedBind(auth.server.BindDN) + } + } + + // bind_dn and bind_password to bind + if err := bindFn(); err != nil { + auth.log.Info("LDAP initial bind failed, %v", err) + + if ldapErr, ok := err.(*LDAP.Error); ok { + if ldapErr.ResultCode == 49 { + return ErrInvalidCredentials + } + } + return err + } + + return nil +} + +func (auth *Auth) secondBind(user *UserInfo, userPassword string) error { + if err := auth.conn.Bind(user.DN, userPassword); err != nil { + auth.log.Info("Second bind failed", "error", err) + + if ldapErr, ok := err.(*LDAP.Error); ok { + if ldapErr.ResultCode == 49 { + return ErrInvalidCredentials + } + } + return err + } + + return nil +} + +func (auth *Auth) initialBind(username, userPassword string) error { + if auth.server.BindPassword != "" || auth.server.BindDN == "" { + userPassword = auth.server.BindPassword + auth.requireSecondBind = true + } + + bindPath := auth.server.BindDN + if strings.Contains(bindPath, "%s") { + bindPath = fmt.Sprintf(auth.server.BindDN, username) + } + + bindFn := func() error { + return auth.conn.Bind(bindPath, userPassword) + } + + if userPassword == "" { + bindFn = func() error { + return auth.conn.UnauthenticatedBind(bindPath) + } + } + + if err := bindFn(); err != nil { + auth.log.Info("Initial bind failed", "error", err) + + if ldapErr, ok := err.(*LDAP.Error); ok { + if ldapErr.ResultCode == 49 { + return ErrInvalidCredentials + } + } + return err + } + + return nil +} + +func (auth *Auth) searchForUser(username string) (*UserInfo, error) { + var searchResult *LDAP.SearchResult + var err error + + for _, searchBase := range auth.server.SearchBaseDNs { + attributes := make([]string, 0) + inputs := auth.server.Attr + attributes = appendIfNotEmpty(attributes, + inputs.Username, + inputs.Surname, + inputs.Email, + inputs.Name, + inputs.MemberOf) + + searchReq := LDAP.SearchRequest{ + BaseDN: searchBase, + Scope: LDAP.ScopeWholeSubtree, + DerefAliases: LDAP.NeverDerefAliases, + Attributes: attributes, + Filter: strings.Replace( + auth.server.SearchFilter, + "%s", LDAP.EscapeFilter(username), + -1, + ), + } + + auth.log.Debug("Ldap Search For User Request", "info", spew.Sdump(searchReq)) + + searchResult, err = auth.conn.Search(&searchReq) + if err != nil { + return nil, err + } + + if len(searchResult.Entries) > 0 { + break + } + } + + if len(searchResult.Entries) == 0 { + return nil, ErrInvalidCredentials + } + + if len(searchResult.Entries) > 1 { + return nil, errors.New("Ldap search matched more than one entry, please review your filter setting") + } + + var memberOf []string + if auth.server.GroupSearchFilter == "" { + memberOf = getLdapAttrArray(auth.server.Attr.MemberOf, searchResult) + } else { + // If we are using a POSIX LDAP schema it won't support memberOf, so we manually search the groups + var groupSearchResult *LDAP.SearchResult + for _, groupSearchBase := range auth.server.GroupSearchBaseDNs { + var filter_replace string + if auth.server.GroupSearchFilterUserAttribute == "" { + filter_replace = getLdapAttr(auth.server.Attr.Username, searchResult) + } else { + filter_replace = getLdapAttr(auth.server.GroupSearchFilterUserAttribute, searchResult) + } + + filter := strings.Replace( + auth.server.GroupSearchFilter, "%s", + LDAP.EscapeFilter(filter_replace), + -1, + ) + + auth.log.Info("Searching for user's groups", "filter", filter) + + // support old way of reading settings + groupIdAttribute := auth.server.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 = auth.conn.Search(&groupSearchReq) + if err != nil { + return nil, err + } + + if len(groupSearchResult.Entries) > 0 { + for i := range groupSearchResult.Entries { + memberOf = append(memberOf, getLdapAttrN(groupIdAttribute, groupSearchResult, i)) + } + break + } + } + } + + return &UserInfo{ + DN: searchResult.Entries[0].DN, + LastName: getLdapAttr(auth.server.Attr.Surname, searchResult), + FirstName: getLdapAttr(auth.server.Attr.Name, searchResult), + Username: getLdapAttr(auth.server.Attr.Username, searchResult), + Email: getLdapAttr(auth.server.Attr.Email, searchResult), + MemberOf: memberOf, + }, nil +} + +func (ldap *Auth) Users() ([]*UserInfo, error) { + var result *LDAP.SearchResult + var err error + server := ldap.server + + if err := ldap.Dial(); err != nil { + return nil, err + } + defer ldap.conn.Close() + + for _, base := range server.SearchBaseDNs { + attributes := make([]string, 0) + inputs := server.Attr + attributes = appendIfNotEmpty( + attributes, + inputs.Username, + inputs.Surname, + inputs.Email, + inputs.Name, + inputs.MemberOf, + ) + + req := LDAP.SearchRequest{ + BaseDN: base, + Scope: LDAP.ScopeWholeSubtree, + DerefAliases: LDAP.NeverDerefAliases, + Attributes: attributes, + + // Doing a star here to get all the users in one go + Filter: strings.Replace(server.SearchFilter, "%s", "*", -1), + } + + result, err = ldap.conn.Search(&req) + if err != nil { + return nil, err + } + + if len(result.Entries) > 0 { + break + } + } + + return ldap.serializeUsers(result), nil +} + +func (ldap *Auth) serializeUsers(users *LDAP.SearchResult) []*UserInfo { + var serialized []*UserInfo + + for index := range users.Entries { + serialize := &UserInfo{ + DN: getLdapAttrN( + "dn", + users, + index, + ), + LastName: getLdapAttrN( + ldap.server.Attr.Surname, + users, + index, + ), + FirstName: getLdapAttrN( + ldap.server.Attr.Name, + users, + index, + ), + Username: getLdapAttrN( + ldap.server.Attr.Username, + users, + index, + ), + Email: getLdapAttrN( + ldap.server.Attr.Email, + users, + index, + ), + MemberOf: getLdapAttrArrayN( + ldap.server.Attr.MemberOf, + users, + index, + ), + } + + serialized = append(serialized, serialize) + } + + return serialized +} + +func appendIfNotEmpty(slice []string, values ...string) []string { + for _, v := range values { + if v != "" { + slice = append(slice, v) + } + } + return slice +} + +func getLdapAttr(name string, result *LDAP.SearchResult) string { + return getLdapAttrN(name, result, 0) +} + +func getLdapAttrN(name string, result *LDAP.SearchResult, n int) string { + if strings.ToLower(name) == "dn" { + return result.Entries[n].DN + } + for _, attr := range result.Entries[n].Attributes { + if attr.Name == name { + if len(attr.Values) > 0 { + return attr.Values[0] + } + } + } + return "" +} + +func getLdapAttrArray(name string, result *LDAP.SearchResult) []string { + return getLdapAttrArrayN(name, result, 0) +} + +func getLdapAttrArrayN(name string, result *LDAP.SearchResult, n int) []string { + for _, attr := range result.Entries[n].Attributes { + if attr.Name == name { + return attr.Values + } + } + return []string{} +} diff --git a/pkg/services/ldap/ldap_login_test.go b/pkg/services/ldap/ldap_login_test.go new file mode 100644 index 00000000000..5afed546246 --- /dev/null +++ b/pkg/services/ldap/ldap_login_test.go @@ -0,0 +1,86 @@ +package ldap + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" + "gopkg.in/ldap.v3" + + "github.com/grafana/grafana/pkg/log" +) + +func TestLdapLogin(t *testing.T) { + Convey("Login using ldap", t, func() { + AuthScenario("When login with invalid credentials", func(scenario *scenarioContext) { + conn := &mockLdapConn{} + entry := ldap.Entry{} + result := ldap.SearchResult{Entries: []*ldap.Entry{&entry}} + conn.setSearchResult(&result) + + conn.bindProvider = func(username, password string) error { + return &ldap.Error{ + ResultCode: 49, + } + } + auth := &Auth{ + server: &ServerConfig{ + Attr: AttributeMap{ + Username: "username", + Name: "name", + MemberOf: "memberof", + }, + SearchBaseDNs: []string{"BaseDNHere"}, + }, + conn: conn, + log: log.New("test-logger"), + } + + err := auth.Login(scenario.loginUserQuery) + + Convey("it should return invalid credentials error", func() { + So(err, ShouldEqual, ErrInvalidCredentials) + }) + }) + + AuthScenario("When login with valid credentials", func(scenario *scenarioContext) { + conn := &mockLdapConn{} + entry := ldap.Entry{ + DN: "dn", Attributes: []*ldap.EntryAttribute{ + {Name: "username", Values: []string{"markelog"}}, + {Name: "surname", Values: []string{"Gaidarenko"}}, + {Name: "email", Values: []string{"markelog@gmail.com"}}, + {Name: "name", Values: []string{"Oleg"}}, + {Name: "memberof", Values: []string{"admins"}}, + }, + } + result := ldap.SearchResult{Entries: []*ldap.Entry{&entry}} + conn.setSearchResult(&result) + + conn.bindProvider = func(username, password string) error { + return nil + } + auth := &Auth{ + server: &ServerConfig{ + Attr: AttributeMap{ + Username: "username", + Name: "name", + MemberOf: "memberof", + }, + SearchBaseDNs: []string{"BaseDNHere"}, + }, + conn: conn, + log: log.New("test-logger"), + } + + err := auth.Login(scenario.loginUserQuery) + + Convey("it should not return error", func() { + So(err, ShouldBeNil) + }) + + Convey("it should get user", func() { + So(scenario.loginUserQuery.User.Login, ShouldEqual, "markelog") + }) + }) + }) +} diff --git a/pkg/login/ldap_test.go b/pkg/services/ldap/ldap_test.go similarity index 57% rename from pkg/login/ldap_test.go rename to pkg/services/ldap/ldap_test.go index 543cc90378c..c5232a54e2b 100644 --- a/pkg/login/ldap_test.go +++ b/pkg/services/ldap/ldap_test.go @@ -1,18 +1,18 @@ -package login +package ldap import ( "context" - "crypto/tls" "testing" + . "github.com/smartystreets/goconvey/convey" + "gopkg.in/ldap.v3" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/log" m "github.com/grafana/grafana/pkg/models" - . "github.com/smartystreets/goconvey/convey" - "gopkg.in/ldap.v3" ) -func TestLdapAuther(t *testing.T) { +func TestAuth(t *testing.T) { Convey("initialBind", t, func() { Convey("Given bind dn and password configured", func() { conn := &mockLdapConn{} @@ -22,16 +22,16 @@ func TestLdapAuther(t *testing.T) { actualPassword = password return nil } - ldapAuther := &ldapAuther{ + Auth := &Auth{ conn: conn, - server: &LdapServerConf{ + server: &ServerConfig{ BindDN: "cn=%s,o=users,dc=grafana,dc=org", BindPassword: "bindpwd", }, } - err := ldapAuther.initialBind("user", "pwd") + err := Auth.initialBind("user", "pwd") So(err, ShouldBeNil) - So(ldapAuther.requireSecondBind, ShouldBeTrue) + So(Auth.requireSecondBind, ShouldBeTrue) So(actualUsername, ShouldEqual, "cn=user,o=users,dc=grafana,dc=org") So(actualPassword, ShouldEqual, "bindpwd") }) @@ -44,15 +44,15 @@ func TestLdapAuther(t *testing.T) { actualPassword = password return nil } - ldapAuther := &ldapAuther{ + Auth := &Auth{ conn: conn, - server: &LdapServerConf{ + server: &ServerConfig{ BindDN: "cn=%s,o=users,dc=grafana,dc=org", }, } - err := ldapAuther.initialBind("user", "pwd") + err := Auth.initialBind("user", "pwd") So(err, ShouldBeNil) - So(ldapAuther.requireSecondBind, ShouldBeFalse) + So(Auth.requireSecondBind, ShouldBeFalse) So(actualUsername, ShouldEqual, "cn=user,o=users,dc=grafana,dc=org") So(actualPassword, ShouldEqual, "pwd") }) @@ -66,13 +66,13 @@ func TestLdapAuther(t *testing.T) { actualUsername = username return nil } - ldapAuther := &ldapAuther{ + Auth := &Auth{ conn: conn, - server: &LdapServerConf{}, + server: &ServerConfig{}, } - err := ldapAuther.initialBind("user", "pwd") + err := Auth.initialBind("user", "pwd") So(err, ShouldBeNil) - So(ldapAuther.requireSecondBind, ShouldBeTrue) + So(Auth.requireSecondBind, ShouldBeTrue) So(unauthenticatedBindWasCalled, ShouldBeTrue) So(actualUsername, ShouldBeEmpty) }) @@ -87,14 +87,14 @@ func TestLdapAuther(t *testing.T) { actualPassword = password return nil } - ldapAuther := &ldapAuther{ + Auth := &Auth{ conn: conn, - server: &LdapServerConf{ + server: &ServerConfig{ BindDN: "o=users,dc=grafana,dc=org", BindPassword: "bindpwd", }, } - err := ldapAuther.serverBind() + err := Auth.serverBind() So(err, ShouldBeNil) So(actualUsername, ShouldEqual, "o=users,dc=grafana,dc=org") So(actualPassword, ShouldEqual, "bindpwd") @@ -109,13 +109,13 @@ func TestLdapAuther(t *testing.T) { actualUsername = username return nil } - ldapAuther := &ldapAuther{ + Auth := &Auth{ conn: conn, - server: &LdapServerConf{ + server: &ServerConfig{ BindDN: "o=users,dc=grafana,dc=org", }, } - err := ldapAuther.serverBind() + err := Auth.serverBind() So(err, ShouldBeNil) So(unauthenticatedBindWasCalled, ShouldBeTrue) So(actualUsername, ShouldEqual, "o=users,dc=grafana,dc=org") @@ -130,11 +130,11 @@ func TestLdapAuther(t *testing.T) { actualUsername = username return nil } - ldapAuther := &ldapAuther{ + Auth := &Auth{ conn: conn, - server: &LdapServerConf{}, + server: &ServerConfig{}, } - err := ldapAuther.serverBind() + err := Auth.serverBind() So(err, ShouldBeNil) So(unauthenticatedBindWasCalled, ShouldBeTrue) So(actualUsername, ShouldBeEmpty) @@ -152,59 +152,59 @@ func TestLdapAuther(t *testing.T) { }) Convey("Given no ldap group map match", func() { - ldapAuther := NewLdapAuthenticator(&LdapServerConf{ - LdapGroups: []*LdapGroupToOrgRole{{}}, + Auth := New(&ServerConfig{ + Groups: []*GroupToOrgRole{{}}, }) - _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{}) + _, err := Auth.GetGrafanaUserFor(nil, &UserInfo{}) So(err, ShouldEqual, ErrInvalidCredentials) }) - ldapAutherScenario("Given wildcard group match", func(sc *scenarioContext) { - ldapAuther := NewLdapAuthenticator(&LdapServerConf{ - LdapGroups: []*LdapGroupToOrgRole{ + AuthScenario("Given wildcard group match", func(sc *scenarioContext) { + Auth := New(&ServerConfig{ + Groups: []*GroupToOrgRole{ {GroupDN: "*", OrgRole: "Admin"}, }, }) sc.userQueryReturns(user1) - result, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{}) + result, err := Auth.GetGrafanaUserFor(nil, &UserInfo{}) So(err, ShouldBeNil) So(result, ShouldEqual, user1) }) - ldapAutherScenario("Given exact group match", func(sc *scenarioContext) { - ldapAuther := NewLdapAuthenticator(&LdapServerConf{ - LdapGroups: []*LdapGroupToOrgRole{ + AuthScenario("Given exact group match", func(sc *scenarioContext) { + Auth := New(&ServerConfig{ + Groups: []*GroupToOrgRole{ {GroupDN: "cn=users", OrgRole: "Admin"}, }, }) sc.userQueryReturns(user1) - result, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{MemberOf: []string{"cn=users"}}) + result, err := Auth.GetGrafanaUserFor(nil, &UserInfo{MemberOf: []string{"cn=users"}}) So(err, ShouldBeNil) So(result, ShouldEqual, user1) }) - ldapAutherScenario("Given group match with different case", func(sc *scenarioContext) { - ldapAuther := NewLdapAuthenticator(&LdapServerConf{ - LdapGroups: []*LdapGroupToOrgRole{ + AuthScenario("Given group match with different case", func(sc *scenarioContext) { + Auth := New(&ServerConfig{ + Groups: []*GroupToOrgRole{ {GroupDN: "cn=users", OrgRole: "Admin"}, }, }) sc.userQueryReturns(user1) - result, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{MemberOf: []string{"CN=users"}}) + result, err := Auth.GetGrafanaUserFor(nil, &UserInfo{MemberOf: []string{"CN=users"}}) So(err, ShouldBeNil) So(result, ShouldEqual, user1) }) - ldapAutherScenario("Given no existing grafana user", func(sc *scenarioContext) { - ldapAuther := NewLdapAuthenticator(&LdapServerConf{ - LdapGroups: []*LdapGroupToOrgRole{ + AuthScenario("Given no existing grafana user", func(sc *scenarioContext) { + Auth := New(&ServerConfig{ + Groups: []*GroupToOrgRole{ {GroupDN: "cn=admin", OrgRole: "Admin"}, {GroupDN: "cn=editor", OrgRole: "Editor"}, {GroupDN: "*", OrgRole: "Viewer"}, @@ -213,7 +213,7 @@ func TestLdapAuther(t *testing.T) { sc.userQueryReturns(nil) - result, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ + result, err := Auth.GetGrafanaUserFor(nil, &UserInfo{ DN: "torkelo", Username: "torkelo", Email: "my@email.com", @@ -235,15 +235,15 @@ func TestLdapAuther(t *testing.T) { }) Convey("When syncing ldap groups to grafana org roles", t, func() { - ldapAutherScenario("given no current user orgs", func(sc *scenarioContext) { - ldapAuther := NewLdapAuthenticator(&LdapServerConf{ - LdapGroups: []*LdapGroupToOrgRole{ + AuthScenario("given no current user orgs", func(sc *scenarioContext) { + Auth := New(&ServerConfig{ + Groups: []*GroupToOrgRole{ {GroupDN: "cn=users", OrgRole: "Admin"}, }, }) sc.userOrgsQueryReturns([]*m.UserOrgDTO{}) - _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ + _, err := Auth.GetGrafanaUserFor(nil, &UserInfo{ MemberOf: []string{"cn=users"}, }) @@ -254,15 +254,15 @@ func TestLdapAuther(t *testing.T) { }) }) - ldapAutherScenario("given different current org role", func(sc *scenarioContext) { - ldapAuther := NewLdapAuthenticator(&LdapServerConf{ - LdapGroups: []*LdapGroupToOrgRole{ + AuthScenario("given different current org role", func(sc *scenarioContext) { + Auth := New(&ServerConfig{ + Groups: []*GroupToOrgRole{ {GroupDN: "cn=users", OrgId: 1, OrgRole: "Admin"}, }, }) sc.userOrgsQueryReturns([]*m.UserOrgDTO{{OrgId: 1, Role: m.ROLE_EDITOR}}) - _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ + _, err := Auth.GetGrafanaUserFor(nil, &UserInfo{ MemberOf: []string{"cn=users"}, }) @@ -274,9 +274,9 @@ func TestLdapAuther(t *testing.T) { }) }) - ldapAutherScenario("given current org role is removed in ldap", func(sc *scenarioContext) { - ldapAuther := NewLdapAuthenticator(&LdapServerConf{ - LdapGroups: []*LdapGroupToOrgRole{ + AuthScenario("given current org role is removed in ldap", func(sc *scenarioContext) { + Auth := New(&ServerConfig{ + Groups: []*GroupToOrgRole{ {GroupDN: "cn=users", OrgId: 2, OrgRole: "Admin"}, }, }) @@ -285,7 +285,7 @@ func TestLdapAuther(t *testing.T) { {OrgId: 1, Role: m.ROLE_EDITOR}, {OrgId: 2, Role: m.ROLE_EDITOR}, }) - _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ + _, err := Auth.GetGrafanaUserFor(nil, &UserInfo{ MemberOf: []string{"cn=users"}, }) @@ -296,16 +296,16 @@ func TestLdapAuther(t *testing.T) { }) }) - ldapAutherScenario("given org role is updated in config", func(sc *scenarioContext) { - ldapAuther := NewLdapAuthenticator(&LdapServerConf{ - LdapGroups: []*LdapGroupToOrgRole{ + AuthScenario("given org role is updated in config", func(sc *scenarioContext) { + Auth := New(&ServerConfig{ + Groups: []*GroupToOrgRole{ {GroupDN: "cn=admin", OrgId: 1, OrgRole: "Admin"}, {GroupDN: "cn=users", OrgId: 1, OrgRole: "Viewer"}, }, }) sc.userOrgsQueryReturns([]*m.UserOrgDTO{{OrgId: 1, Role: m.ROLE_EDITOR}}) - _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ + _, err := Auth.GetGrafanaUserFor(nil, &UserInfo{ MemberOf: []string{"cn=users"}, }) @@ -317,16 +317,16 @@ func TestLdapAuther(t *testing.T) { }) }) - ldapAutherScenario("given multiple matching ldap groups", func(sc *scenarioContext) { - ldapAuther := NewLdapAuthenticator(&LdapServerConf{ - LdapGroups: []*LdapGroupToOrgRole{ + AuthScenario("given multiple matching ldap groups", func(sc *scenarioContext) { + Auth := New(&ServerConfig{ + Groups: []*GroupToOrgRole{ {GroupDN: "cn=admins", OrgId: 1, OrgRole: "Admin"}, {GroupDN: "*", OrgId: 1, OrgRole: "Viewer"}, }, }) sc.userOrgsQueryReturns([]*m.UserOrgDTO{{OrgId: 1, Role: m.ROLE_ADMIN}}) - _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ + _, err := Auth.GetGrafanaUserFor(nil, &UserInfo{ MemberOf: []string{"cn=admins"}, }) @@ -337,16 +337,16 @@ func TestLdapAuther(t *testing.T) { }) }) - ldapAutherScenario("given multiple matching ldap groups and no existing groups", func(sc *scenarioContext) { - ldapAuther := NewLdapAuthenticator(&LdapServerConf{ - LdapGroups: []*LdapGroupToOrgRole{ + AuthScenario("given multiple matching ldap groups and no existing groups", func(sc *scenarioContext) { + Auth := New(&ServerConfig{ + Groups: []*GroupToOrgRole{ {GroupDN: "cn=admins", OrgId: 1, OrgRole: "Admin"}, {GroupDN: "*", OrgId: 1, OrgRole: "Viewer"}, }, }) sc.userOrgsQueryReturns([]*m.UserOrgDTO{}) - _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ + _, err := Auth.GetGrafanaUserFor(nil, &UserInfo{ MemberOf: []string{"cn=admins"}, }) @@ -362,17 +362,17 @@ func TestLdapAuther(t *testing.T) { }) }) - ldapAutherScenario("given ldap groups with grafana_admin=true", func(sc *scenarioContext) { + AuthScenario("given ldap groups with grafana_admin=true", func(sc *scenarioContext) { trueVal := true - ldapAuther := NewLdapAuthenticator(&LdapServerConf{ - LdapGroups: []*LdapGroupToOrgRole{ + Auth := New(&ServerConfig{ + Groups: []*GroupToOrgRole{ {GroupDN: "cn=admins", OrgId: 1, OrgRole: "Admin", IsGrafanaAdmin: &trueVal}, }, }) sc.userOrgsQueryReturns([]*m.UserOrgDTO{}) - _, err := ldapAuther.GetGrafanaUserFor(nil, &LdapUserInfo{ + _, err := Auth.GetGrafanaUserFor(nil, &UserInfo{ MemberOf: []string{"cn=admins"}, }) @@ -384,16 +384,16 @@ func TestLdapAuther(t *testing.T) { }) Convey("When calling SyncUser", t, func() { - mockLdapConnection := &mockLdapConn{} - ldapAuther := NewLdapAuthenticator( - &LdapServerConf{ + + auth := &Auth{ + server: &ServerConfig{ Host: "", RootCACert: "", - LdapGroups: []*LdapGroupToOrgRole{ + Groups: []*GroupToOrgRole{ {GroupDN: "*", OrgRole: "Admin"}, }, - Attr: LdapAttributeMap{ + Attr: AttributeMap{ Username: "username", Surname: "surname", Email: "email", @@ -402,10 +402,12 @@ func TestLdapAuther(t *testing.T) { }, SearchBaseDNs: []string{"BaseDNHere"}, }, - ) + conn: mockLdapConnection, + log: log.New("test-logger"), + } dialCalled := false - ldapDial = func(network, addr string) (ILdapConn, error) { + dial = func(network, addr string) (IConnection, error) { dialCalled = true return mockLdapConnection, nil } @@ -421,12 +423,14 @@ func TestLdapAuther(t *testing.T) { result := ldap.SearchResult{Entries: []*ldap.Entry{&entry}} mockLdapConnection.setSearchResult(&result) - ldapAutherScenario("When ldapUser found call syncInfo and orgRoles", func(sc *scenarioContext) { + AuthScenario("When ldapUser found call syncInfo and orgRoles", func(sc *scenarioContext) { // arrange query := &m.LoginUserQuery{ Username: "roelgerrits", } + hookDial = nil + sc.userQueryReturns(&m.User{ Id: 1, Email: "roel@test.net", @@ -436,7 +440,7 @@ func TestLdapAuther(t *testing.T) { sc.userOrgsQueryReturns([]*m.UserOrgDTO{}) // act - syncErrResult := ldapAuther.SyncUser(query) + syncErrResult := auth.SyncUser(query) // assert So(dialCalled, ShouldBeTrue) @@ -465,9 +469,9 @@ func TestLdapAuther(t *testing.T) { mockLdapConnection.setSearchResult(&result) // Set up attribute map without surname and email - ldapAuther := &ldapAuther{ - server: &LdapServerConf{ - Attr: LdapAttributeMap{ + Auth := &Auth{ + server: &ServerConfig{ + Attr: AttributeMap{ Username: "username", Name: "name", MemberOf: "memberof", @@ -478,7 +482,7 @@ func TestLdapAuther(t *testing.T) { log: log.New("test-logger"), } - searchResult, err := ldapAuther.searchForUser("roelgerrits") + searchResult, err := Auth.searchForUser("roelgerrits") So(err, ShouldBeNil) So(searchResult, ShouldNotBeNil) @@ -490,143 +494,3 @@ func TestLdapAuther(t *testing.T) { So(len(mockLdapConnection.searchAttributes), ShouldEqual, 3) }) } - -type mockLdapConn struct { - result *ldap.SearchResult - searchCalled bool - searchAttributes []string - bindProvider func(username, password string) error - unauthenticatedBindProvider func(username string) error -} - -func (c *mockLdapConn) Bind(username, password string) error { - if c.bindProvider != nil { - return c.bindProvider(username, password) - } - - return nil -} - -func (c *mockLdapConn) UnauthenticatedBind(username string) error { - if c.unauthenticatedBindProvider != nil { - return c.unauthenticatedBindProvider(username) - } - - return nil -} - -func (c *mockLdapConn) Close() {} - -func (c *mockLdapConn) setSearchResult(result *ldap.SearchResult) { - c.result = result -} - -func (c *mockLdapConn) Search(sr *ldap.SearchRequest) (*ldap.SearchResult, error) { - c.searchCalled = true - c.searchAttributes = sr.Attributes - return c.result, nil -} - -func (c *mockLdapConn) StartTLS(*tls.Config) error { - return nil -} - -func ldapAutherScenario(desc string, fn scenarioFunc) { - Convey(desc, func() { - defer bus.ClearBusHandlers() - - sc := &scenarioContext{} - loginService := &LoginService{ - Bus: bus.GetBus(), - } - - bus.AddHandler("test", loginService.UpsertUser) - - bus.AddHandlerCtx("test", func(ctx context.Context, cmd *m.SyncTeamsCommand) error { - return nil - }) - - bus.AddHandlerCtx("test", func(ctx context.Context, cmd *m.UpdateUserPermissionsCommand) error { - sc.updateUserPermissionsCmd = cmd - return nil - }) - - bus.AddHandler("test", func(cmd *m.GetUserByAuthInfoQuery) error { - sc.getUserByAuthInfoQuery = cmd - sc.getUserByAuthInfoQuery.Result = &m.User{Login: cmd.Login} - return nil - }) - - bus.AddHandler("test", func(cmd *m.GetUserOrgListQuery) error { - sc.getUserOrgListQuery = cmd - return nil - }) - - bus.AddHandler("test", func(cmd *m.CreateUserCommand) error { - sc.createUserCmd = cmd - sc.createUserCmd.Result = m.User{Login: cmd.Login} - return nil - }) - - bus.AddHandler("test", func(cmd *m.AddOrgUserCommand) error { - sc.addOrgUserCmd = cmd - return nil - }) - - bus.AddHandler("test", func(cmd *m.UpdateOrgUserCommand) error { - sc.updateOrgUserCmd = cmd - return nil - }) - - bus.AddHandler("test", func(cmd *m.RemoveOrgUserCommand) error { - sc.removeOrgUserCmd = cmd - return nil - }) - - bus.AddHandler("test", func(cmd *m.UpdateUserCommand) error { - sc.updateUserCmd = cmd - return nil - }) - - bus.AddHandler("test", func(cmd *m.SetUsingOrgCommand) error { - sc.setUsingOrgCmd = cmd - return nil - }) - - fn(sc) - }) -} - -type scenarioContext struct { - getUserByAuthInfoQuery *m.GetUserByAuthInfoQuery - getUserOrgListQuery *m.GetUserOrgListQuery - createUserCmd *m.CreateUserCommand - addOrgUserCmd *m.AddOrgUserCommand - updateOrgUserCmd *m.UpdateOrgUserCommand - removeOrgUserCmd *m.RemoveOrgUserCommand - updateUserCmd *m.UpdateUserCommand - setUsingOrgCmd *m.SetUsingOrgCommand - updateUserPermissionsCmd *m.UpdateUserPermissionsCommand -} - -func (sc *scenarioContext) userQueryReturns(user *m.User) { - bus.AddHandler("test", func(query *m.GetUserByAuthInfoQuery) error { - if user == nil { - return m.ErrUserNotFound - } - query.Result = user - return nil - }) - bus.AddHandler("test", func(query *m.SetAuthInfoCommand) error { - return nil - }) -} - -func (sc *scenarioContext) userOrgsQueryReturns(orgs []*m.UserOrgDTO) { - bus.AddHandler("test", func(query *m.GetUserOrgListQuery) error { - query.Result = orgs - return nil - }) -} - -type scenarioFunc func(c *scenarioContext) diff --git a/pkg/services/ldap/settings.go b/pkg/services/ldap/settings.go new file mode 100644 index 00000000000..c726fff3968 --- /dev/null +++ b/pkg/services/ldap/settings.go @@ -0,0 +1,126 @@ +package ldap + +import ( + "fmt" + "os" + + "github.com/BurntSushi/toml" + + "github.com/grafana/grafana/pkg/log" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" +) + +type Config struct { + Servers []*ServerConfig `toml:"servers"` +} + +type ServerConfig struct { + Host string `toml:"host"` + Port int `toml:"port"` + UseSSL bool `toml:"use_ssl"` + StartTLS bool `toml:"start_tls"` + SkipVerifySSL bool `toml:"ssl_skip_verify"` + RootCACert string `toml:"root_ca_cert"` + ClientCert string `toml:"client_cert"` + ClientKey string `toml:"client_key"` + BindDN string `toml:"bind_dn"` + BindPassword string `toml:"bind_password"` + Attr AttributeMap `toml:"attributes"` + + SearchFilter string `toml:"search_filter"` + SearchBaseDNs []string `toml:"search_base_dns"` + + GroupSearchFilter string `toml:"group_search_filter"` + GroupSearchFilterUserAttribute string `toml:"group_search_filter_user_attribute"` + GroupSearchBaseDNs []string `toml:"group_search_base_dns"` + + Groups []*GroupToOrgRole `toml:"group_mappings"` +} + +type AttributeMap struct { + Username string `toml:"username"` + Name string `toml:"name"` + Surname string `toml:"surname"` + Email string `toml:"email"` + MemberOf string `toml:"member_of"` +} + +type GroupToOrgRole struct { + GroupDN string `toml:"group_dn"` + OrgId int64 `toml:"org_id"` + IsGrafanaAdmin *bool `toml:"grafana_admin"` // This is a pointer to know if it was set or not (for backwards compatibility) + OrgRole m.RoleType `toml:"org_role"` +} + +var config *Config +var logger = log.New("ldap") + +// IsEnabled checks if ldap is enabled +func IsEnabled() bool { + return setting.LdapEnabled +} + +// ReadConfig reads the config if +// ldap is enabled otherwise it will return nil +func ReadConfig() *Config { + if IsEnabled() == false { + return nil + } + + // Make it a singleton + if config != nil { + return config + } + + config = getConfig(setting.LdapConfigFile) + + return config +} +func getConfig(configFile string) *Config { + result := &Config{} + + logger.Info("Ldap enabled, reading config file", "file", configFile) + + _, err := toml.DecodeFile(configFile, result) + if err != nil { + logger.Crit("Failed to load ldap config file", "error", err) + os.Exit(1) + } + + if len(result.Servers) == 0 { + logger.Crit("ldap enabled but no ldap servers defined in config file") + os.Exit(1) + } + + // set default org id + for _, server := range result.Servers { + assertNotEmptyCfg(server.SearchFilter, "search_filter") + assertNotEmptyCfg(server.SearchBaseDNs, "search_base_dns") + + for _, groupMap := range server.Groups { + if groupMap.OrgId == 0 { + groupMap.OrgId = 1 + } + } + } + + return result +} + +func assertNotEmptyCfg(val interface{}, propName string) { + switch v := val.(type) { + case string: + if v == "" { + logger.Crit("LDAP config file is missing option", "option", propName) + os.Exit(1) + } + case []string: + if len(v) == 0 { + logger.Crit("LDAP config file is missing option", "option", propName) + os.Exit(1) + } + default: + fmt.Println("unknown") + } +} diff --git a/pkg/services/ldap/test.go b/pkg/services/ldap/test.go new file mode 100644 index 00000000000..98d169b9a1a --- /dev/null +++ b/pkg/services/ldap/test.go @@ -0,0 +1,165 @@ +package ldap + +import ( + "context" + "crypto/tls" + + . "github.com/smartystreets/goconvey/convey" + "gopkg.in/ldap.v3" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/login" +) + +type mockLdapConn struct { + result *ldap.SearchResult + searchCalled bool + searchAttributes []string + bindProvider func(username, password string) error + unauthenticatedBindProvider func(username string) error +} + +func (c *mockLdapConn) Bind(username, password string) error { + if c.bindProvider != nil { + return c.bindProvider(username, password) + } + + return nil +} + +func (c *mockLdapConn) UnauthenticatedBind(username string) error { + if c.unauthenticatedBindProvider != nil { + return c.unauthenticatedBindProvider(username) + } + + return nil +} + +func (c *mockLdapConn) Close() {} + +func (c *mockLdapConn) setSearchResult(result *ldap.SearchResult) { + c.result = result +} + +func (c *mockLdapConn) Search(sr *ldap.SearchRequest) (*ldap.SearchResult, error) { + c.searchCalled = true + c.searchAttributes = sr.Attributes + return c.result, nil +} + +func (c *mockLdapConn) StartTLS(*tls.Config) error { + return nil +} + +func AuthScenario(desc string, fn scenarioFunc) { + Convey(desc, func() { + defer bus.ClearBusHandlers() + + sc := &scenarioContext{ + loginUserQuery: &models.LoginUserQuery{ + Username: "user", + Password: "pwd", + IpAddress: "192.168.1.1:56433", + }, + } + + hookDial = func(auth *Auth) error { + return nil + } + + loginService := &login.LoginService{ + Bus: bus.GetBus(), + } + + bus.AddHandler("test", loginService.UpsertUser) + + bus.AddHandlerCtx("test", func(ctx context.Context, cmd *models.SyncTeamsCommand) error { + return nil + }) + + bus.AddHandlerCtx("test", func(ctx context.Context, cmd *models.UpdateUserPermissionsCommand) error { + sc.updateUserPermissionsCmd = cmd + return nil + }) + + bus.AddHandler("test", func(cmd *models.GetUserByAuthInfoQuery) error { + sc.getUserByAuthInfoQuery = cmd + sc.getUserByAuthInfoQuery.Result = &models.User{Login: cmd.Login} + return nil + }) + + bus.AddHandler("test", func(cmd *models.GetUserOrgListQuery) error { + sc.getUserOrgListQuery = cmd + return nil + }) + + bus.AddHandler("test", func(cmd *models.CreateUserCommand) error { + sc.createUserCmd = cmd + sc.createUserCmd.Result = models.User{Login: cmd.Login} + return nil + }) + + bus.AddHandler("test", func(cmd *models.AddOrgUserCommand) error { + sc.addOrgUserCmd = cmd + return nil + }) + + bus.AddHandler("test", func(cmd *models.UpdateOrgUserCommand) error { + sc.updateOrgUserCmd = cmd + return nil + }) + + bus.AddHandler("test", func(cmd *models.RemoveOrgUserCommand) error { + sc.removeOrgUserCmd = cmd + return nil + }) + + bus.AddHandler("test", func(cmd *models.UpdateUserCommand) error { + sc.updateUserCmd = cmd + return nil + }) + + bus.AddHandler("test", func(cmd *models.SetUsingOrgCommand) error { + sc.setUsingOrgCmd = cmd + return nil + }) + + fn(sc) + }) +} + +type scenarioContext struct { + loginUserQuery *models.LoginUserQuery + getUserByAuthInfoQuery *models.GetUserByAuthInfoQuery + getUserOrgListQuery *models.GetUserOrgListQuery + createUserCmd *models.CreateUserCommand + addOrgUserCmd *models.AddOrgUserCommand + updateOrgUserCmd *models.UpdateOrgUserCommand + removeOrgUserCmd *models.RemoveOrgUserCommand + updateUserCmd *models.UpdateUserCommand + setUsingOrgCmd *models.SetUsingOrgCommand + updateUserPermissionsCmd *models.UpdateUserPermissionsCommand +} + +func (sc *scenarioContext) userQueryReturns(user *models.User) { + bus.AddHandler("test", func(query *models.GetUserByAuthInfoQuery) error { + if user == nil { + return models.ErrUserNotFound + } + query.Result = user + return nil + }) + bus.AddHandler("test", func(query *models.SetAuthInfoCommand) error { + return nil + }) +} + +func (sc *scenarioContext) userOrgsQueryReturns(orgs []*models.UserOrgDTO) { + bus.AddHandler("test", func(query *models.GetUserOrgListQuery) error { + query.Result = orgs + return nil + }) +} + +type scenarioFunc func(c *scenarioContext) diff --git a/pkg/login/ldap_user.go b/pkg/services/ldap/user.go similarity index 75% rename from pkg/login/ldap_user.go rename to pkg/services/ldap/user.go index 3651d9e5e23..0e4a0063da2 100644 --- a/pkg/login/ldap_user.go +++ b/pkg/services/ldap/user.go @@ -1,10 +1,10 @@ -package login +package ldap import ( "strings" ) -type LdapUserInfo struct { +type UserInfo struct { DN string FirstName string LastName string @@ -13,7 +13,7 @@ type LdapUserInfo struct { MemberOf []string } -func (u *LdapUserInfo) isMemberOf(group string) bool { +func (u *UserInfo) isMemberOf(group string) bool { if group == "*" { return true } diff --git a/pkg/services/login/errors.go b/pkg/services/login/errors.go new file mode 100644 index 00000000000..0889b4613a6 --- /dev/null +++ b/pkg/services/login/errors.go @@ -0,0 +1,15 @@ +package login + +import "errors" + +var ( + ErrEmailNotAllowed = errors.New("Required email domain not fulfilled") + ErrInvalidCredentials = errors.New("Invalid Username or Password") + ErrNoEmail = errors.New("Login provider didn't return an email address") + ErrProviderDeniedRequest = errors.New("Login provider denied login request") + ErrSignUpNotAllowed = errors.New("Signup is not allowed for this adapter") + ErrTooManyLoginAttempts = errors.New("Too many consecutive incorrect login attempts for user. Login for user temporarily blocked") + ErrPasswordEmpty = errors.New("No password provided.") + ErrUsersQuotaReached = errors.New("Users quota reached") + ErrGettingUserQuota = errors.New("Error getting user quota") +) diff --git a/pkg/login/ext_user.go b/pkg/services/login/login.go similarity index 99% rename from pkg/login/ext_user.go rename to pkg/services/login/login.go index e698110c9c9..9b2a258dea0 100644 --- a/pkg/login/ext_user.go +++ b/pkg/services/login/login.go @@ -93,6 +93,7 @@ func (ls *LoginService) UpsertUser(cmd *m.UpsertUserCommand) error { } err = syncOrgRoles(cmd.Result, extUser) + if err != nil { return err } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 2aab0001182..d6a6ca5767d 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -869,14 +869,8 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { analytics := iniFile.Section("analytics") ReportingEnabled = analytics.Key("reporting_enabled").MustBool(true) CheckForUpdates = analytics.Key("check_for_updates").MustBool(true) - GoogleAnalyticsId, err = valueAsString(analytics, "google_analytics_ua_id", "") - if err != nil { - return err - } - GoogleTagManagerId, err = valueAsString(analytics, "google_tag_manager_id", "") - if err != nil { - return err - } + GoogleAnalyticsId = analytics.Key("google_analytics_ua_id").String() + GoogleTagManagerId = analytics.Key("google_tag_manager_id").String() alerting := iniFile.Section("alerting") AlertingEnabled = alerting.Key("enabled").MustBool(true) diff --git a/scripts/backend-lint.sh b/scripts/backend-lint.sh index 2954243dd82..304f91550e0 100755 --- a/scripts/backend-lint.sh +++ b/scripts/backend-lint.sh @@ -20,7 +20,7 @@ go get -u github.com/golangci/golangci-lint/cmd/golangci-lint # use gometalinter when lints are not available in golangci or # when gometalinter is better. Eg. goconst for gometalinter does not lint test files -# which is not desired. +# which is not desired. exit_if_fail gometalinter --enable-gc --vendor --deadline 10m --disable-all \ --enable=goconst\ --enable=staticcheck diff --git a/vendor/github.com/robfig/cron/.gitignore b/vendor/github.com/robfig/cron/.gitignore new file mode 100644 index 00000000000..00268614f04 --- /dev/null +++ b/vendor/github.com/robfig/cron/.gitignore @@ -0,0 +1,22 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe diff --git a/vendor/github.com/robfig/cron/LICENSE b/vendor/github.com/robfig/cron/LICENSE new file mode 100644 index 00000000000..3a0f627ffeb --- /dev/null +++ b/vendor/github.com/robfig/cron/LICENSE @@ -0,0 +1,21 @@ +Copyright (C) 2012 Rob Figueiredo +All Rights Reserved. + +MIT LICENSE + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/robfig/cron/README.md b/vendor/github.com/robfig/cron/README.md new file mode 100644 index 00000000000..4e0ae1c25f3 --- /dev/null +++ b/vendor/github.com/robfig/cron/README.md @@ -0,0 +1,6 @@ +[![GoDoc](http://godoc.org/github.com/robfig/cron?status.png)](http://godoc.org/github.com/robfig/cron) +[![Build Status](https://travis-ci.org/robfig/cron.svg?branch=master)](https://travis-ci.org/robfig/cron) + +# cron + +Documentation here: https://godoc.org/github.com/robfig/cron diff --git a/vendor/github.com/robfig/cron/constantdelay.go b/vendor/github.com/robfig/cron/constantdelay.go new file mode 100644 index 00000000000..cd6e7b1be91 --- /dev/null +++ b/vendor/github.com/robfig/cron/constantdelay.go @@ -0,0 +1,27 @@ +package cron + +import "time" + +// ConstantDelaySchedule represents a simple recurring duty cycle, e.g. "Every 5 minutes". +// It does not support jobs more frequent than once a second. +type ConstantDelaySchedule struct { + Delay time.Duration +} + +// Every returns a crontab Schedule that activates once every duration. +// Delays of less than a second are not supported (will round up to 1 second). +// Any fields less than a Second are truncated. +func Every(duration time.Duration) ConstantDelaySchedule { + if duration < time.Second { + duration = time.Second + } + return ConstantDelaySchedule{ + Delay: duration - time.Duration(duration.Nanoseconds())%time.Second, + } +} + +// Next returns the next time this should be run. +// This rounds so that the next activation time will be on the second. +func (schedule ConstantDelaySchedule) Next(t time.Time) time.Time { + return t.Add(schedule.Delay - time.Duration(t.Nanosecond())*time.Nanosecond) +} diff --git a/vendor/github.com/robfig/cron/cron.go b/vendor/github.com/robfig/cron/cron.go new file mode 100644 index 00000000000..2318aeb2e7d --- /dev/null +++ b/vendor/github.com/robfig/cron/cron.go @@ -0,0 +1,259 @@ +package cron + +import ( + "log" + "runtime" + "sort" + "time" +) + +// Cron keeps track of any number of entries, invoking the associated func as +// specified by the schedule. It may be started, stopped, and the entries may +// be inspected while running. +type Cron struct { + entries []*Entry + stop chan struct{} + add chan *Entry + snapshot chan []*Entry + running bool + ErrorLog *log.Logger + location *time.Location +} + +// Job is an interface for submitted cron jobs. +type Job interface { + Run() +} + +// The Schedule describes a job's duty cycle. +type Schedule interface { + // Return the next activation time, later than the given time. + // Next is invoked initially, and then each time the job is run. + Next(time.Time) time.Time +} + +// Entry consists of a schedule and the func to execute on that schedule. +type Entry struct { + // The schedule on which this job should be run. + Schedule Schedule + + // The next time the job will run. This is the zero time if Cron has not been + // started or this entry's schedule is unsatisfiable + Next time.Time + + // The last time this job was run. This is the zero time if the job has never + // been run. + Prev time.Time + + // The Job to run. + Job Job +} + +// byTime is a wrapper for sorting the entry array by time +// (with zero time at the end). +type byTime []*Entry + +func (s byTime) Len() int { return len(s) } +func (s byTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +func (s byTime) Less(i, j int) bool { + // Two zero times should return false. + // Otherwise, zero is "greater" than any other time. + // (To sort it at the end of the list.) + if s[i].Next.IsZero() { + return false + } + if s[j].Next.IsZero() { + return true + } + return s[i].Next.Before(s[j].Next) +} + +// New returns a new Cron job runner, in the Local time zone. +func New() *Cron { + return NewWithLocation(time.Now().Location()) +} + +// NewWithLocation returns a new Cron job runner. +func NewWithLocation(location *time.Location) *Cron { + return &Cron{ + entries: nil, + add: make(chan *Entry), + stop: make(chan struct{}), + snapshot: make(chan []*Entry), + running: false, + ErrorLog: nil, + location: location, + } +} + +// A wrapper that turns a func() into a cron.Job +type FuncJob func() + +func (f FuncJob) Run() { f() } + +// AddFunc adds a func to the Cron to be run on the given schedule. +func (c *Cron) AddFunc(spec string, cmd func()) error { + return c.AddJob(spec, FuncJob(cmd)) +} + +// AddJob adds a Job to the Cron to be run on the given schedule. +func (c *Cron) AddJob(spec string, cmd Job) error { + schedule, err := Parse(spec) + if err != nil { + return err + } + c.Schedule(schedule, cmd) + return nil +} + +// Schedule adds a Job to the Cron to be run on the given schedule. +func (c *Cron) Schedule(schedule Schedule, cmd Job) { + entry := &Entry{ + Schedule: schedule, + Job: cmd, + } + if !c.running { + c.entries = append(c.entries, entry) + return + } + + c.add <- entry +} + +// Entries returns a snapshot of the cron entries. +func (c *Cron) Entries() []*Entry { + if c.running { + c.snapshot <- nil + x := <-c.snapshot + return x + } + return c.entrySnapshot() +} + +// Location gets the time zone location +func (c *Cron) Location() *time.Location { + return c.location +} + +// Start the cron scheduler in its own go-routine, or no-op if already started. +func (c *Cron) Start() { + if c.running { + return + } + c.running = true + go c.run() +} + +// Run the cron scheduler, or no-op if already running. +func (c *Cron) Run() { + if c.running { + return + } + c.running = true + c.run() +} + +func (c *Cron) runWithRecovery(j Job) { + defer func() { + if r := recover(); r != nil { + const size = 64 << 10 + buf := make([]byte, size) + buf = buf[:runtime.Stack(buf, false)] + c.logf("cron: panic running job: %v\n%s", r, buf) + } + }() + j.Run() +} + +// Run the scheduler. this is private just due to the need to synchronize +// access to the 'running' state variable. +func (c *Cron) run() { + // Figure out the next activation times for each entry. + now := c.now() + for _, entry := range c.entries { + entry.Next = entry.Schedule.Next(now) + } + + for { + // Determine the next entry to run. + sort.Sort(byTime(c.entries)) + + var timer *time.Timer + if len(c.entries) == 0 || c.entries[0].Next.IsZero() { + // If there are no entries yet, just sleep - it still handles new entries + // and stop requests. + timer = time.NewTimer(100000 * time.Hour) + } else { + timer = time.NewTimer(c.entries[0].Next.Sub(now)) + } + + for { + select { + case now = <-timer.C: + now = now.In(c.location) + // Run every entry whose next time was less than now + for _, e := range c.entries { + if e.Next.After(now) || e.Next.IsZero() { + break + } + go c.runWithRecovery(e.Job) + e.Prev = e.Next + e.Next = e.Schedule.Next(now) + } + + case newEntry := <-c.add: + timer.Stop() + now = c.now() + newEntry.Next = newEntry.Schedule.Next(now) + c.entries = append(c.entries, newEntry) + + case <-c.snapshot: + c.snapshot <- c.entrySnapshot() + continue + + case <-c.stop: + timer.Stop() + return + } + + break + } + } +} + +// Logs an error to stderr or to the configured error log +func (c *Cron) logf(format string, args ...interface{}) { + if c.ErrorLog != nil { + c.ErrorLog.Printf(format, args...) + } else { + log.Printf(format, args...) + } +} + +// Stop stops the cron scheduler if it is running; otherwise it does nothing. +func (c *Cron) Stop() { + if !c.running { + return + } + c.stop <- struct{}{} + c.running = false +} + +// entrySnapshot returns a copy of the current cron entry list. +func (c *Cron) entrySnapshot() []*Entry { + entries := []*Entry{} + for _, e := range c.entries { + entries = append(entries, &Entry{ + Schedule: e.Schedule, + Next: e.Next, + Prev: e.Prev, + Job: e.Job, + }) + } + return entries +} + +// now returns current time in c location +func (c *Cron) now() time.Time { + return time.Now().In(c.location) +} diff --git a/vendor/github.com/robfig/cron/doc.go b/vendor/github.com/robfig/cron/doc.go new file mode 100644 index 00000000000..1ce84f7bf46 --- /dev/null +++ b/vendor/github.com/robfig/cron/doc.go @@ -0,0 +1,129 @@ +/* +Package cron implements a cron spec parser and job runner. + +Usage + +Callers may register Funcs to be invoked on a given schedule. Cron will run +them in their own goroutines. + + c := cron.New() + c.AddFunc("0 30 * * * *", func() { fmt.Println("Every hour on the half hour") }) + c.AddFunc("@hourly", func() { fmt.Println("Every hour") }) + c.AddFunc("@every 1h30m", func() { fmt.Println("Every hour thirty") }) + c.Start() + .. + // Funcs are invoked in their own goroutine, asynchronously. + ... + // Funcs may also be added to a running Cron + c.AddFunc("@daily", func() { fmt.Println("Every day") }) + .. + // Inspect the cron job entries' next and previous run times. + inspect(c.Entries()) + .. + c.Stop() // Stop the scheduler (does not stop any jobs already running). + +CRON Expression Format + +A cron expression represents a set of times, using 6 space-separated fields. + + Field name | Mandatory? | Allowed values | Allowed special characters + ---------- | ---------- | -------------- | -------------------------- + Seconds | Yes | 0-59 | * / , - + Minutes | Yes | 0-59 | * / , - + Hours | Yes | 0-23 | * / , - + Day of month | Yes | 1-31 | * / , - ? + Month | Yes | 1-12 or JAN-DEC | * / , - + Day of week | Yes | 0-6 or SUN-SAT | * / , - ? + +Note: Month and Day-of-week field values are case insensitive. "SUN", "Sun", +and "sun" are equally accepted. + +Special Characters + +Asterisk ( * ) + +The asterisk indicates that the cron expression will match for all values of the +field; e.g., using an asterisk in the 5th field (month) would indicate every +month. + +Slash ( / ) + +Slashes are used to describe increments of ranges. For example 3-59/15 in the +1st field (minutes) would indicate the 3rd minute of the hour and every 15 +minutes thereafter. The form "*\/..." is equivalent to the form "first-last/...", +that is, an increment over the largest possible range of the field. The form +"N/..." is accepted as meaning "N-MAX/...", that is, starting at N, use the +increment until the end of that specific range. It does not wrap around. + +Comma ( , ) + +Commas are used to separate items of a list. For example, using "MON,WED,FRI" in +the 5th field (day of week) would mean Mondays, Wednesdays and Fridays. + +Hyphen ( - ) + +Hyphens are used to define ranges. For example, 9-17 would indicate every +hour between 9am and 5pm inclusive. + +Question mark ( ? ) + +Question mark may be used instead of '*' for leaving either day-of-month or +day-of-week blank. + +Predefined schedules + +You may use one of several pre-defined schedules in place of a cron expression. + + Entry | Description | Equivalent To + ----- | ----------- | ------------- + @yearly (or @annually) | Run once a year, midnight, Jan. 1st | 0 0 0 1 1 * + @monthly | Run once a month, midnight, first of month | 0 0 0 1 * * + @weekly | Run once a week, midnight between Sat/Sun | 0 0 0 * * 0 + @daily (or @midnight) | Run once a day, midnight | 0 0 0 * * * + @hourly | Run once an hour, beginning of hour | 0 0 * * * * + +Intervals + +You may also schedule a job to execute at fixed intervals, starting at the time it's added +or cron is run. This is supported by formatting the cron spec like this: + + @every + +where "duration" is a string accepted by time.ParseDuration +(http://golang.org/pkg/time/#ParseDuration). + +For example, "@every 1h30m10s" would indicate a schedule that activates after +1 hour, 30 minutes, 10 seconds, and then every interval after that. + +Note: The interval does not take the job runtime into account. For example, +if a job takes 3 minutes to run, and it is scheduled to run every 5 minutes, +it will have only 2 minutes of idle time between each run. + +Time zones + +All interpretation and scheduling is done in the machine's local time zone (as +provided by the Go time package (http://www.golang.org/pkg/time). + +Be aware that jobs scheduled during daylight-savings leap-ahead transitions will +not be run! + +Thread safety + +Since the Cron service runs concurrently with the calling code, some amount of +care must be taken to ensure proper synchronization. + +All cron methods are designed to be correctly synchronized as long as the caller +ensures that invocations have a clear happens-before ordering between them. + +Implementation + +Cron entries are stored in an array, sorted by their next activation time. Cron +sleeps until the next job is due to be run. + +Upon waking: + - it runs each entry that is active on that second + - it calculates the next run times for the jobs that were run + - it re-sorts the array of entries by next activation time. + - it goes to sleep until the soonest job. +*/ +package cron diff --git a/vendor/github.com/robfig/cron/parser.go b/vendor/github.com/robfig/cron/parser.go new file mode 100644 index 00000000000..a5e83c0a8dc --- /dev/null +++ b/vendor/github.com/robfig/cron/parser.go @@ -0,0 +1,380 @@ +package cron + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" +) + +// Configuration options for creating a parser. Most options specify which +// fields should be included, while others enable features. If a field is not +// included the parser will assume a default value. These options do not change +// the order fields are parse in. +type ParseOption int + +const ( + Second ParseOption = 1 << iota // Seconds field, default 0 + Minute // Minutes field, default 0 + Hour // Hours field, default 0 + Dom // Day of month field, default * + Month // Month field, default * + Dow // Day of week field, default * + DowOptional // Optional day of week field, default * + Descriptor // Allow descriptors such as @monthly, @weekly, etc. +) + +var places = []ParseOption{ + Second, + Minute, + Hour, + Dom, + Month, + Dow, +} + +var defaults = []string{ + "0", + "0", + "0", + "*", + "*", + "*", +} + +// A custom Parser that can be configured. +type Parser struct { + options ParseOption + optionals int +} + +// Creates a custom Parser with custom options. +// +// // Standard parser without descriptors +// specParser := NewParser(Minute | Hour | Dom | Month | Dow) +// sched, err := specParser.Parse("0 0 15 */3 *") +// +// // Same as above, just excludes time fields +// subsParser := NewParser(Dom | Month | Dow) +// sched, err := specParser.Parse("15 */3 *") +// +// // Same as above, just makes Dow optional +// subsParser := NewParser(Dom | Month | DowOptional) +// sched, err := specParser.Parse("15 */3") +// +func NewParser(options ParseOption) Parser { + optionals := 0 + if options&DowOptional > 0 { + options |= Dow + optionals++ + } + return Parser{options, optionals} +} + +// Parse returns a new crontab schedule representing the given spec. +// It returns a descriptive error if the spec is not valid. +// It accepts crontab specs and features configured by NewParser. +func (p Parser) Parse(spec string) (Schedule, error) { + if len(spec) == 0 { + return nil, fmt.Errorf("Empty spec string") + } + if spec[0] == '@' && p.options&Descriptor > 0 { + return parseDescriptor(spec) + } + + // Figure out how many fields we need + max := 0 + for _, place := range places { + if p.options&place > 0 { + max++ + } + } + min := max - p.optionals + + // Split fields on whitespace + fields := strings.Fields(spec) + + // Validate number of fields + if count := len(fields); count < min || count > max { + if min == max { + return nil, fmt.Errorf("Expected exactly %d fields, found %d: %s", min, count, spec) + } + return nil, fmt.Errorf("Expected %d to %d fields, found %d: %s", min, max, count, spec) + } + + // Fill in missing fields + fields = expandFields(fields, p.options) + + var err error + field := func(field string, r bounds) uint64 { + if err != nil { + return 0 + } + var bits uint64 + bits, err = getField(field, r) + return bits + } + + var ( + second = field(fields[0], seconds) + minute = field(fields[1], minutes) + hour = field(fields[2], hours) + dayofmonth = field(fields[3], dom) + month = field(fields[4], months) + dayofweek = field(fields[5], dow) + ) + if err != nil { + return nil, err + } + + return &SpecSchedule{ + Second: second, + Minute: minute, + Hour: hour, + Dom: dayofmonth, + Month: month, + Dow: dayofweek, + }, nil +} + +func expandFields(fields []string, options ParseOption) []string { + n := 0 + count := len(fields) + expFields := make([]string, len(places)) + copy(expFields, defaults) + for i, place := range places { + if options&place > 0 { + expFields[i] = fields[n] + n++ + } + if n == count { + break + } + } + return expFields +} + +var standardParser = NewParser( + Minute | Hour | Dom | Month | Dow | Descriptor, +) + +// ParseStandard returns a new crontab schedule representing the given standardSpec +// (https://en.wikipedia.org/wiki/Cron). It differs from Parse requiring to always +// pass 5 entries representing: minute, hour, day of month, month and day of week, +// in that order. It returns a descriptive error if the spec is not valid. +// +// It accepts +// - Standard crontab specs, e.g. "* * * * ?" +// - Descriptors, e.g. "@midnight", "@every 1h30m" +func ParseStandard(standardSpec string) (Schedule, error) { + return standardParser.Parse(standardSpec) +} + +var defaultParser = NewParser( + Second | Minute | Hour | Dom | Month | DowOptional | Descriptor, +) + +// Parse returns a new crontab schedule representing the given spec. +// It returns a descriptive error if the spec is not valid. +// +// It accepts +// - Full crontab specs, e.g. "* * * * * ?" +// - Descriptors, e.g. "@midnight", "@every 1h30m" +func Parse(spec string) (Schedule, error) { + return defaultParser.Parse(spec) +} + +// getField returns an Int with the bits set representing all of the times that +// the field represents or error parsing field value. A "field" is a comma-separated +// list of "ranges". +func getField(field string, r bounds) (uint64, error) { + var bits uint64 + ranges := strings.FieldsFunc(field, func(r rune) bool { return r == ',' }) + for _, expr := range ranges { + bit, err := getRange(expr, r) + if err != nil { + return bits, err + } + bits |= bit + } + return bits, nil +} + +// getRange returns the bits indicated by the given expression: +// number | number "-" number [ "/" number ] +// or error parsing range. +func getRange(expr string, r bounds) (uint64, error) { + var ( + start, end, step uint + rangeAndStep = strings.Split(expr, "/") + lowAndHigh = strings.Split(rangeAndStep[0], "-") + singleDigit = len(lowAndHigh) == 1 + err error + ) + + var extra uint64 + if lowAndHigh[0] == "*" || lowAndHigh[0] == "?" { + start = r.min + end = r.max + extra = starBit + } else { + start, err = parseIntOrName(lowAndHigh[0], r.names) + if err != nil { + return 0, err + } + switch len(lowAndHigh) { + case 1: + end = start + case 2: + end, err = parseIntOrName(lowAndHigh[1], r.names) + if err != nil { + return 0, err + } + default: + return 0, fmt.Errorf("Too many hyphens: %s", expr) + } + } + + switch len(rangeAndStep) { + case 1: + step = 1 + case 2: + step, err = mustParseInt(rangeAndStep[1]) + if err != nil { + return 0, err + } + + // Special handling: "N/step" means "N-max/step". + if singleDigit { + end = r.max + } + default: + return 0, fmt.Errorf("Too many slashes: %s", expr) + } + + if start < r.min { + return 0, fmt.Errorf("Beginning of range (%d) below minimum (%d): %s", start, r.min, expr) + } + if end > r.max { + return 0, fmt.Errorf("End of range (%d) above maximum (%d): %s", end, r.max, expr) + } + if start > end { + return 0, fmt.Errorf("Beginning of range (%d) beyond end of range (%d): %s", start, end, expr) + } + if step == 0 { + return 0, fmt.Errorf("Step of range should be a positive number: %s", expr) + } + + return getBits(start, end, step) | extra, nil +} + +// parseIntOrName returns the (possibly-named) integer contained in expr. +func parseIntOrName(expr string, names map[string]uint) (uint, error) { + if names != nil { + if namedInt, ok := names[strings.ToLower(expr)]; ok { + return namedInt, nil + } + } + return mustParseInt(expr) +} + +// mustParseInt parses the given expression as an int or returns an error. +func mustParseInt(expr string) (uint, error) { + num, err := strconv.Atoi(expr) + if err != nil { + return 0, fmt.Errorf("Failed to parse int from %s: %s", expr, err) + } + if num < 0 { + return 0, fmt.Errorf("Negative number (%d) not allowed: %s", num, expr) + } + + return uint(num), nil +} + +// getBits sets all bits in the range [min, max], modulo the given step size. +func getBits(min, max, step uint) uint64 { + var bits uint64 + + // If step is 1, use shifts. + if step == 1 { + return ^(math.MaxUint64 << (max + 1)) & (math.MaxUint64 << min) + } + + // Else, use a simple loop. + for i := min; i <= max; i += step { + bits |= 1 << i + } + return bits +} + +// all returns all bits within the given bounds. (plus the star bit) +func all(r bounds) uint64 { + return getBits(r.min, r.max, 1) | starBit +} + +// parseDescriptor returns a predefined schedule for the expression, or error if none matches. +func parseDescriptor(descriptor string) (Schedule, error) { + switch descriptor { + case "@yearly", "@annually": + return &SpecSchedule{ + Second: 1 << seconds.min, + Minute: 1 << minutes.min, + Hour: 1 << hours.min, + Dom: 1 << dom.min, + Month: 1 << months.min, + Dow: all(dow), + }, nil + + case "@monthly": + return &SpecSchedule{ + Second: 1 << seconds.min, + Minute: 1 << minutes.min, + Hour: 1 << hours.min, + Dom: 1 << dom.min, + Month: all(months), + Dow: all(dow), + }, nil + + case "@weekly": + return &SpecSchedule{ + Second: 1 << seconds.min, + Minute: 1 << minutes.min, + Hour: 1 << hours.min, + Dom: all(dom), + Month: all(months), + Dow: 1 << dow.min, + }, nil + + case "@daily", "@midnight": + return &SpecSchedule{ + Second: 1 << seconds.min, + Minute: 1 << minutes.min, + Hour: 1 << hours.min, + Dom: all(dom), + Month: all(months), + Dow: all(dow), + }, nil + + case "@hourly": + return &SpecSchedule{ + Second: 1 << seconds.min, + Minute: 1 << minutes.min, + Hour: all(hours), + Dom: all(dom), + Month: all(months), + Dow: all(dow), + }, nil + } + + const every = "@every " + if strings.HasPrefix(descriptor, every) { + duration, err := time.ParseDuration(descriptor[len(every):]) + if err != nil { + return nil, fmt.Errorf("Failed to parse duration %s: %s", descriptor, err) + } + return Every(duration), nil + } + + return nil, fmt.Errorf("Unrecognized descriptor: %s", descriptor) +} diff --git a/vendor/github.com/robfig/cron/spec.go b/vendor/github.com/robfig/cron/spec.go new file mode 100644 index 00000000000..aac9a60b954 --- /dev/null +++ b/vendor/github.com/robfig/cron/spec.go @@ -0,0 +1,158 @@ +package cron + +import "time" + +// SpecSchedule specifies a duty cycle (to the second granularity), based on a +// traditional crontab specification. It is computed initially and stored as bit sets. +type SpecSchedule struct { + Second, Minute, Hour, Dom, Month, Dow uint64 +} + +// bounds provides a range of acceptable values (plus a map of name to value). +type bounds struct { + min, max uint + names map[string]uint +} + +// The bounds for each field. +var ( + seconds = bounds{0, 59, nil} + minutes = bounds{0, 59, nil} + hours = bounds{0, 23, nil} + dom = bounds{1, 31, nil} + months = bounds{1, 12, map[string]uint{ + "jan": 1, + "feb": 2, + "mar": 3, + "apr": 4, + "may": 5, + "jun": 6, + "jul": 7, + "aug": 8, + "sep": 9, + "oct": 10, + "nov": 11, + "dec": 12, + }} + dow = bounds{0, 6, map[string]uint{ + "sun": 0, + "mon": 1, + "tue": 2, + "wed": 3, + "thu": 4, + "fri": 5, + "sat": 6, + }} +) + +const ( + // Set the top bit if a star was included in the expression. + starBit = 1 << 63 +) + +// Next returns the next time this schedule is activated, greater than the given +// time. If no time can be found to satisfy the schedule, return the zero time. +func (s *SpecSchedule) Next(t time.Time) time.Time { + // General approach: + // For Month, Day, Hour, Minute, Second: + // Check if the time value matches. If yes, continue to the next field. + // If the field doesn't match the schedule, then increment the field until it matches. + // While incrementing the field, a wrap-around brings it back to the beginning + // of the field list (since it is necessary to re-verify previous field + // values) + + // Start at the earliest possible time (the upcoming second). + t = t.Add(1*time.Second - time.Duration(t.Nanosecond())*time.Nanosecond) + + // This flag indicates whether a field has been incremented. + added := false + + // If no time is found within five years, return zero. + yearLimit := t.Year() + 5 + +WRAP: + if t.Year() > yearLimit { + return time.Time{} + } + + // Find the first applicable month. + // If it's this month, then do nothing. + for 1< 0 + dowMatch bool = 1< 0 + ) + if s.Dom&starBit > 0 || s.Dow&starBit > 0 { + return domMatch && dowMatch + } + return domMatch || dowMatch +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 0c4f02ccdab..19e66874848 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -175,6 +175,8 @@ github.com/prometheus/procfs/xfs github.com/prometheus/procfs/internal/util # github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be github.com/rainycape/unidecode +# github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967 +github.com/robfig/cron # github.com/sergi/go-diff v1.0.0 github.com/sergi/go-diff/diffmatchpatch # github.com/smartystreets/assertions v0.0.0-20190401211740-f487f9de1cd3