فهرست منبع

LDAP Refactoring to support syncronizing more than one user at a time. (#16705)

* Feature: add cron setting for the ldap settings

* Move ldap configuration read to special function

* Introduce cron setting (no docs for it yet, pending approval)

* Chore: duplicate ldap module as a service

* Feature: implement active sync

This is very early preliminary implementation of active sync.
There is only one thing that's going right for this code - it works.

Aside from that, there is no tests, error handling, docs, transactions,
it's very much duplicative and etc.

But this is the overall direction with architecture I'm going for

* Chore: introduce login service

* Chore: gradually switch to ldap service

* Chore: use new approach for auth_proxy

* Chore: use new approach along with refactoring

* Chore: use new ldap interface for auth_proxy

* Chore: improve auth_proxy and subsequently ldap

* Chore: more of the refactoring bits

* Chore: address comments from code review

* Chore: more refactoring stuff

* Chore: make linter happy

* Chore: add cron dep for grafana enterprise

* Chore: initialize config package var

* Chore: disable gosec for now

* Chore: update dependencies

* Chore: remove unused module

* Chore: address review comments

* Chore: make linter happy
Oleg Gaidarenko 6 سال پیش
والد
کامیت
62b85a886e

+ 1 - 0
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

+ 1 - 0
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

+ 2 - 0
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=

+ 1 - 0
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"
 )
 

+ 4 - 3
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)
 	}
 

+ 7 - 5
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)

+ 2 - 1
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) {

+ 0 - 430
pkg/login/ldap.go

@@ -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{}
-}

+ 21 - 9
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
 }

+ 51 - 60
pkg/login/ldap_login_test.go

@@ -1,71 +1,41 @@
 package login
 
 import (
+	"errors"
 	"testing"
 
+	. "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"
-	. "github.com/smartystreets/goconvey/convey"
 )
 
+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() {
+		Convey("Given ldap enabled and no server configured", func() {
 			setting.LdapEnabled = true
-			LdapCfg.Servers = append(LdapCfg.Servers,
-				&LdapServerConf{
-					Host: "",
-				})
 
-			ldapLoginScenario("When login with invalid credentials", func(sc *ldapLoginScenarioContext) {
+			ldapLoginScenario("When login", 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)
-				})
+				readLDAPConfig = func() *LDAP.Config {
+					config := &LDAP.Config{
+						Servers: []*LDAP.ServerConfig{},
+					}
 
-				Convey("it should not return error", func() {
-					So(err, ShouldBeNil)
-				})
+					return config
+				}
 
-				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)
 				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)

+ 0 - 104
pkg/login/ldap_settings.go

@@ -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")
-	}
-}

+ 22 - 13
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)

+ 39 - 13
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)
 			})

+ 5 - 0
pkg/services/ldap/hooks.go

@@ -0,0 +1,5 @@
+package ldap
+
+var (
+	hookDial func(*Auth) error
+)

+ 559 - 0
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{}
+}

+ 86 - 0
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")
+			})
+		})
+	})
+}

+ 90 - 226
pkg/login/ldap_test.go → 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)

+ 126 - 0
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")
+	}
+}

+ 165 - 0
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)

+ 3 - 3
pkg/login/ldap_user.go → 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
 	}

+ 15 - 0
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")
+)

+ 1 - 0
pkg/login/ext_user.go → 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
 	}

+ 2 - 8
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)

+ 1 - 1
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

+ 22 - 0
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

+ 21 - 0
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.

+ 6 - 0
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

+ 27 - 0
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)
+}

+ 259 - 0
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)
+}

+ 129 - 0
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 <duration>
+
+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

+ 380 - 0
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)
+}

+ 158 - 0
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<<uint(t.Month())&s.Month == 0 {
+		// If we have to add a month, reset the other parts to 0.
+		if !added {
+			added = true
+			// Otherwise, set the date at the beginning (since the current time is irrelevant).
+			t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
+		}
+		t = t.AddDate(0, 1, 0)
+
+		// Wrapped around.
+		if t.Month() == time.January {
+			goto WRAP
+		}
+	}
+
+	// Now get a day in that month.
+	for !dayMatches(s, t) {
+		if !added {
+			added = true
+			t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
+		}
+		t = t.AddDate(0, 0, 1)
+
+		if t.Day() == 1 {
+			goto WRAP
+		}
+	}
+
+	for 1<<uint(t.Hour())&s.Hour == 0 {
+		if !added {
+			added = true
+			t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location())
+		}
+		t = t.Add(1 * time.Hour)
+
+		if t.Hour() == 0 {
+			goto WRAP
+		}
+	}
+
+	for 1<<uint(t.Minute())&s.Minute == 0 {
+		if !added {
+			added = true
+			t = t.Truncate(time.Minute)
+		}
+		t = t.Add(1 * time.Minute)
+
+		if t.Minute() == 0 {
+			goto WRAP
+		}
+	}
+
+	for 1<<uint(t.Second())&s.Second == 0 {
+		if !added {
+			added = true
+			t = t.Truncate(time.Second)
+		}
+		t = t.Add(1 * time.Second)
+
+		if t.Second() == 0 {
+			goto WRAP
+		}
+	}
+
+	return t
+}
+
+// dayMatches returns true if the schedule's day-of-week and day-of-month
+// restrictions are satisfied by the given time.
+func dayMatches(s *SpecSchedule, t time.Time) bool {
+	var (
+		domMatch bool = 1<<uint(t.Day())&s.Dom > 0
+		dowMatch bool = 1<<uint(t.Weekday())&s.Dow > 0
+	)
+	if s.Dom&starBit > 0 || s.Dow&starBit > 0 {
+		return domMatch && dowMatch
+	}
+	return domMatch || dowMatch
+}

+ 2 - 0
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