mirror of https://github.com/grafana/grafana.git
				
				
				
			WIP: Protect against brute force (frequent) login attempts (#10031)
* db: add login attempt migrations * db: add possibility to create login attempts * db: add possibility to retrieve login attempt count per username * auth: validation and update of login attempts for invalid credentials If login attempt count for user authenticating is 5 or more the last 5 minutes we temporarily block the user access to login * db: add possibility to delete expired login attempts * cleanup: Delete login attempts older than 10 minutes The cleanup job are running continuously and triggering each 10 minute * fix typo: rename consequent to consequent * auth: enable login attempt validation for ldap logins * auth: disable login attempts validation by configuration Setting is named DisableLoginAttemptsValidation and is false by default Config disable_login_attempts_validation is placed under security section #7616 * auth: don't run cleanup of login attempts if feature is disabled #7616 * auth: rename settings.go to ldap_settings.go * auth: refactor AuthenticateUser Extract grafana login, ldap login and login attemp validation together with their tests to separate files. Enables testing of many more aspects when authenticating a user. #7616 * auth: rename login attempt validation to brute force login protection Setting DisableLoginAttemptsValidation => DisableBruteForceLoginProtection Configuration disable_login_attempts_validation => disable_brute_force_login_protection #7616
This commit is contained in:
		
							parent
							
								
									475febd004
								
							
						
					
					
						commit
						3d1c624c12
					
				|  | @ -174,6 +174,9 @@ disable_gravatar = false | ||||||
| # data source proxy whitelist (ip_or_domain:port separated by spaces) | # data source proxy whitelist (ip_or_domain:port separated by spaces) | ||||||
| data_source_proxy_whitelist = | data_source_proxy_whitelist = | ||||||
| 
 | 
 | ||||||
|  | # disable protection against brute force login attempts | ||||||
|  | disable_brute_force_login_protection = false | ||||||
|  | 
 | ||||||
| #################################### Snapshots ########################### | #################################### Snapshots ########################### | ||||||
| [snapshots] | [snapshots] | ||||||
| # snapshot sharing options | # snapshot sharing options | ||||||
|  |  | ||||||
|  | @ -162,6 +162,9 @@ log_queries = | ||||||
| # data source proxy whitelist (ip_or_domain:port separated by spaces) | # data source proxy whitelist (ip_or_domain:port separated by spaces) | ||||||
| ;data_source_proxy_whitelist = | ;data_source_proxy_whitelist = | ||||||
| 
 | 
 | ||||||
|  | # disable protection against brute force login attempts | ||||||
|  | ;disable_brute_force_login_protection = false | ||||||
|  | 
 | ||||||
| #################################### Snapshots ########################### | #################################### Snapshots ########################### | ||||||
| [snapshots] | [snapshots] | ||||||
| # snapshot sharing options | # snapshot sharing options | ||||||
|  |  | ||||||
|  | @ -102,12 +102,13 @@ func LoginPost(c *middleware.Context, cmd dtos.LoginCommand) Response { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	authQuery := login.LoginUserQuery{ | 	authQuery := login.LoginUserQuery{ | ||||||
| 		Username: cmd.User, | 		Username:  cmd.User, | ||||||
| 		Password: cmd.Password, | 		Password:  cmd.Password, | ||||||
|  | 		IpAddress: c.Req.RemoteAddr, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if err := bus.Dispatch(&authQuery); err != nil { | 	if err := bus.Dispatch(&authQuery); err != nil { | ||||||
| 		if err == login.ErrInvalidCredentials { | 		if err == login.ErrInvalidCredentials || err == login.ErrTooManyLoginAttempts { | ||||||
| 			return ApiError(401, "Invalid username or password", err) | 			return ApiError(401, "Invalid username or password", err) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -3,21 +3,20 @@ package login | ||||||
| import ( | import ( | ||||||
| 	"errors" | 	"errors" | ||||||
| 
 | 
 | ||||||
| 	"crypto/subtle" |  | ||||||
| 	"github.com/grafana/grafana/pkg/bus" | 	"github.com/grafana/grafana/pkg/bus" | ||||||
| 	m "github.com/grafana/grafana/pkg/models" | 	m "github.com/grafana/grafana/pkg/models" | ||||||
| 	"github.com/grafana/grafana/pkg/setting" |  | ||||||
| 	"github.com/grafana/grafana/pkg/util" |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| var ( | var ( | ||||||
| 	ErrInvalidCredentials = errors.New("Invalid Username or Password") | 	ErrInvalidCredentials   = errors.New("Invalid Username or Password") | ||||||
|  | 	ErrTooManyLoginAttempts = errors.New("Too many consecutive incorrect login attempts for user. Login for user temporarily blocked") | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type LoginUserQuery struct { | type LoginUserQuery struct { | ||||||
| 	Username string | 	Username  string | ||||||
| 	Password string | 	Password  string | ||||||
| 	User     *m.User | 	User      *m.User | ||||||
|  | 	IpAddress string | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func Init() { | func Init() { | ||||||
|  | @ -26,41 +25,31 @@ func Init() { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func AuthenticateUser(query *LoginUserQuery) error { | func AuthenticateUser(query *LoginUserQuery) error { | ||||||
| 	err := loginUsingGrafanaDB(query) | 	if err := validateLoginAttempts(query.Username); err != nil { | ||||||
| 	if err == nil || err != ErrInvalidCredentials { |  | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if setting.LdapEnabled { | 	err := loginUsingGrafanaDB(query) | ||||||
| 		for _, server := range LdapCfg.Servers { | 	if err == nil || (err != m.ErrUserNotFound && err != ErrInvalidCredentials) { | ||||||
| 			author := NewLdapAuthenticator(server) | 		return err | ||||||
| 			err = author.Login(query) | 	} | ||||||
| 			if err == nil || err != ErrInvalidCredentials { | 
 | ||||||
| 				return err | 	ldapEnabled, ldapErr := loginUsingLdap(query) | ||||||
| 			} | 	if ldapEnabled { | ||||||
|  | 		if ldapErr == nil || ldapErr != ErrInvalidCredentials { | ||||||
|  | 			return ldapErr | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
|  | 		err = ldapErr | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if err == ErrInvalidCredentials { | ||||||
|  | 		saveInvalidLoginAttempt(query) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if err == m.ErrUserNotFound { | ||||||
|  | 		return ErrInvalidCredentials | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return err | 	return err | ||||||
| } | } | ||||||
| 
 |  | ||||||
| func loginUsingGrafanaDB(query *LoginUserQuery) error { |  | ||||||
| 	userQuery := m.GetUserByLoginQuery{LoginOrEmail: query.Username} |  | ||||||
| 
 |  | ||||||
| 	if err := bus.Dispatch(&userQuery); err != nil { |  | ||||||
| 		if err == m.ErrUserNotFound { |  | ||||||
| 			return ErrInvalidCredentials |  | ||||||
| 		} |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	user := userQuery.Result |  | ||||||
| 
 |  | ||||||
| 	passwordHashed := util.EncodePassword(query.Password, user.Salt) |  | ||||||
| 	if subtle.ConstantTimeCompare([]byte(passwordHashed), []byte(user.Password)) != 1 { |  | ||||||
| 		return ErrInvalidCredentials |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	query.User = user |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -0,0 +1,214 @@ | ||||||
|  | package login | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"errors" | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	m "github.com/grafana/grafana/pkg/models" | ||||||
|  | 	. "github.com/smartystreets/goconvey/convey" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func TestAuthenticateUser(t *testing.T) { | ||||||
|  | 	Convey("Authenticate user", t, func() { | ||||||
|  | 		authScenario("When a user authenticates having too many login attempts", func(sc *authScenarioContext) { | ||||||
|  | 			mockLoginAttemptValidation(ErrTooManyLoginAttempts, sc) | ||||||
|  | 			mockLoginUsingGrafanaDB(nil, sc) | ||||||
|  | 			mockLoginUsingLdap(true, nil, sc) | ||||||
|  | 			mockSaveInvalidLoginAttempt(sc) | ||||||
|  | 
 | ||||||
|  | 			err := AuthenticateUser(sc.loginUserQuery) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should result in", func() { | ||||||
|  | 				So(err, ShouldEqual, ErrTooManyLoginAttempts) | ||||||
|  | 				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.grafanaLoginWasCalled, ShouldBeFalse) | ||||||
|  | 				So(sc.ldapLoginWasCalled, ShouldBeFalse) | ||||||
|  | 				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		authScenario("When grafana user authenticate with valid credentials", func(sc *authScenarioContext) { | ||||||
|  | 			mockLoginAttemptValidation(nil, sc) | ||||||
|  | 			mockLoginUsingGrafanaDB(nil, sc) | ||||||
|  | 			mockLoginUsingLdap(true, ErrInvalidCredentials, sc) | ||||||
|  | 			mockSaveInvalidLoginAttempt(sc) | ||||||
|  | 
 | ||||||
|  | 			err := AuthenticateUser(sc.loginUserQuery) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should result in", func() { | ||||||
|  | 				So(err, ShouldEqual, nil) | ||||||
|  | 				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.grafanaLoginWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.ldapLoginWasCalled, ShouldBeFalse) | ||||||
|  | 				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		authScenario("When grafana user authenticate and unexpected error occurs", func(sc *authScenarioContext) { | ||||||
|  | 			customErr := errors.New("custom") | ||||||
|  | 			mockLoginAttemptValidation(nil, sc) | ||||||
|  | 			mockLoginUsingGrafanaDB(customErr, sc) | ||||||
|  | 			mockLoginUsingLdap(true, ErrInvalidCredentials, sc) | ||||||
|  | 			mockSaveInvalidLoginAttempt(sc) | ||||||
|  | 
 | ||||||
|  | 			err := AuthenticateUser(sc.loginUserQuery) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should result in", func() { | ||||||
|  | 				So(err, ShouldEqual, customErr) | ||||||
|  | 				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.grafanaLoginWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.ldapLoginWasCalled, ShouldBeFalse) | ||||||
|  | 				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		authScenario("When a non-existing grafana user authenticate and ldap disabled", func(sc *authScenarioContext) { | ||||||
|  | 			mockLoginAttemptValidation(nil, sc) | ||||||
|  | 			mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) | ||||||
|  | 			mockLoginUsingLdap(false, nil, sc) | ||||||
|  | 			mockSaveInvalidLoginAttempt(sc) | ||||||
|  | 
 | ||||||
|  | 			err := AuthenticateUser(sc.loginUserQuery) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should result in", func() { | ||||||
|  | 				So(err, ShouldEqual, ErrInvalidCredentials) | ||||||
|  | 				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.grafanaLoginWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.ldapLoginWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		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) | ||||||
|  | 			mockSaveInvalidLoginAttempt(sc) | ||||||
|  | 
 | ||||||
|  | 			err := AuthenticateUser(sc.loginUserQuery) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should result in", func() { | ||||||
|  | 				So(err, ShouldEqual, ErrInvalidCredentials) | ||||||
|  | 				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.grafanaLoginWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.ldapLoginWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeTrue) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		authScenario("When a non-existing grafana user authenticate and valid ldap credentials", func(sc *authScenarioContext) { | ||||||
|  | 			mockLoginAttemptValidation(nil, sc) | ||||||
|  | 			mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) | ||||||
|  | 			mockLoginUsingLdap(true, nil, sc) | ||||||
|  | 			mockSaveInvalidLoginAttempt(sc) | ||||||
|  | 
 | ||||||
|  | 			err := AuthenticateUser(sc.loginUserQuery) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should result in", func() { | ||||||
|  | 				So(err, ShouldBeNil) | ||||||
|  | 				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.grafanaLoginWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.ldapLoginWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		authScenario("When a non-existing grafana user authenticate and ldap returns unexpected error", func(sc *authScenarioContext) { | ||||||
|  | 			customErr := errors.New("custom") | ||||||
|  | 			mockLoginAttemptValidation(nil, sc) | ||||||
|  | 			mockLoginUsingGrafanaDB(m.ErrUserNotFound, sc) | ||||||
|  | 			mockLoginUsingLdap(true, customErr, sc) | ||||||
|  | 			mockSaveInvalidLoginAttempt(sc) | ||||||
|  | 
 | ||||||
|  | 			err := AuthenticateUser(sc.loginUserQuery) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should result in", func() { | ||||||
|  | 				So(err, ShouldEqual, customErr) | ||||||
|  | 				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.grafanaLoginWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.ldapLoginWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeFalse) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		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) | ||||||
|  | 			mockSaveInvalidLoginAttempt(sc) | ||||||
|  | 
 | ||||||
|  | 			err := AuthenticateUser(sc.loginUserQuery) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should result in", func() { | ||||||
|  | 				So(err, ShouldEqual, ErrInvalidCredentials) | ||||||
|  | 				So(sc.loginAttemptValidationWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.grafanaLoginWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.ldapLoginWasCalled, ShouldBeTrue) | ||||||
|  | 				So(sc.saveInvalidLoginAttemptWasCalled, ShouldBeTrue) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type authScenarioContext struct { | ||||||
|  | 	loginUserQuery                   *LoginUserQuery | ||||||
|  | 	grafanaLoginWasCalled            bool | ||||||
|  | 	ldapLoginWasCalled               bool | ||||||
|  | 	loginAttemptValidationWasCalled  bool | ||||||
|  | 	saveInvalidLoginAttemptWasCalled bool | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type authScenarioFunc func(sc *authScenarioContext) | ||||||
|  | 
 | ||||||
|  | func mockLoginUsingGrafanaDB(err error, sc *authScenarioContext) { | ||||||
|  | 	loginUsingGrafanaDB = func(query *LoginUserQuery) error { | ||||||
|  | 		sc.grafanaLoginWasCalled = true | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func mockLoginUsingLdap(enabled bool, err error, sc *authScenarioContext) { | ||||||
|  | 	loginUsingLdap = func(query *LoginUserQuery) (bool, error) { | ||||||
|  | 		sc.ldapLoginWasCalled = true | ||||||
|  | 		return enabled, err | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func mockLoginAttemptValidation(err error, sc *authScenarioContext) { | ||||||
|  | 	validateLoginAttempts = func(username string) error { | ||||||
|  | 		sc.loginAttemptValidationWasCalled = true | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func mockSaveInvalidLoginAttempt(sc *authScenarioContext) { | ||||||
|  | 	saveInvalidLoginAttempt = func(query *LoginUserQuery) { | ||||||
|  | 		sc.saveInvalidLoginAttemptWasCalled = true | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func authScenario(desc string, fn authScenarioFunc) { | ||||||
|  | 	Convey(desc, func() { | ||||||
|  | 		origLoginUsingGrafanaDB := loginUsingGrafanaDB | ||||||
|  | 		origLoginUsingLdap := loginUsingLdap | ||||||
|  | 		origValidateLoginAttempts := validateLoginAttempts | ||||||
|  | 		origSaveInvalidLoginAttempt := saveInvalidLoginAttempt | ||||||
|  | 
 | ||||||
|  | 		sc := &authScenarioContext{ | ||||||
|  | 			loginUserQuery: &LoginUserQuery{ | ||||||
|  | 				Username:  "user", | ||||||
|  | 				Password:  "pwd", | ||||||
|  | 				IpAddress: "192.168.1.1:56433", | ||||||
|  | 			}, | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		defer func() { | ||||||
|  | 			loginUsingGrafanaDB = origLoginUsingGrafanaDB | ||||||
|  | 			loginUsingLdap = origLoginUsingLdap | ||||||
|  | 			validateLoginAttempts = origValidateLoginAttempts | ||||||
|  | 			saveInvalidLoginAttempt = origSaveInvalidLoginAttempt | ||||||
|  | 		}() | ||||||
|  | 
 | ||||||
|  | 		fn(sc) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | @ -0,0 +1,48 @@ | ||||||
|  | package login | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"github.com/grafana/grafana/pkg/bus" | ||||||
|  | 	m "github.com/grafana/grafana/pkg/models" | ||||||
|  | 	"github.com/grafana/grafana/pkg/setting" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var ( | ||||||
|  | 	maxInvalidLoginAttempts int64         = 5 | ||||||
|  | 	loginAttemptsWindow     time.Duration = time.Minute * 5 | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var validateLoginAttempts = func(username string) error { | ||||||
|  | 	if setting.DisableBruteForceLoginProtection { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	loginAttemptCountQuery := m.GetUserLoginAttemptCountQuery{ | ||||||
|  | 		Username: username, | ||||||
|  | 		Since:    time.Now().Add(-loginAttemptsWindow), | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if err := bus.Dispatch(&loginAttemptCountQuery); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if loginAttemptCountQuery.Result >= maxInvalidLoginAttempts { | ||||||
|  | 		return ErrTooManyLoginAttempts | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | var saveInvalidLoginAttempt = func(query *LoginUserQuery) { | ||||||
|  | 	if setting.DisableBruteForceLoginProtection { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	loginAttemptCommand := m.CreateLoginAttemptCommand{ | ||||||
|  | 		Username:  query.Username, | ||||||
|  | 		IpAddress: query.IpAddress, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	bus.Dispatch(&loginAttemptCommand) | ||||||
|  | } | ||||||
|  | @ -0,0 +1,125 @@ | ||||||
|  | package login | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/grafana/grafana/pkg/bus" | ||||||
|  | 	m "github.com/grafana/grafana/pkg/models" | ||||||
|  | 	"github.com/grafana/grafana/pkg/setting" | ||||||
|  | 	. "github.com/smartystreets/goconvey/convey" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func TestLoginAttemptsValidation(t *testing.T) { | ||||||
|  | 	Convey("Validate login attempts", t, func() { | ||||||
|  | 		Convey("Given brute force login protection enabled", func() { | ||||||
|  | 			setting.DisableBruteForceLoginProtection = false | ||||||
|  | 
 | ||||||
|  | 			Convey("When user login attempt count equals max-1 ", func() { | ||||||
|  | 				withLoginAttempts(maxInvalidLoginAttempts - 1) | ||||||
|  | 				err := validateLoginAttempts("user") | ||||||
|  | 
 | ||||||
|  | 				Convey("it should not result in error", func() { | ||||||
|  | 					So(err, ShouldBeNil) | ||||||
|  | 				}) | ||||||
|  | 			}) | ||||||
|  | 
 | ||||||
|  | 			Convey("When user login attempt count equals max ", func() { | ||||||
|  | 				withLoginAttempts(maxInvalidLoginAttempts) | ||||||
|  | 				err := validateLoginAttempts("user") | ||||||
|  | 
 | ||||||
|  | 				Convey("it should result in too many login attempts error", func() { | ||||||
|  | 					So(err, ShouldEqual, ErrTooManyLoginAttempts) | ||||||
|  | 				}) | ||||||
|  | 			}) | ||||||
|  | 
 | ||||||
|  | 			Convey("When user login attempt count is greater than max ", func() { | ||||||
|  | 				withLoginAttempts(maxInvalidLoginAttempts + 5) | ||||||
|  | 				err := validateLoginAttempts("user") | ||||||
|  | 
 | ||||||
|  | 				Convey("it should result in too many login attempts error", func() { | ||||||
|  | 					So(err, ShouldEqual, ErrTooManyLoginAttempts) | ||||||
|  | 				}) | ||||||
|  | 			}) | ||||||
|  | 
 | ||||||
|  | 			Convey("When saving invalid login attempt", func() { | ||||||
|  | 				defer bus.ClearBusHandlers() | ||||||
|  | 				createLoginAttemptCmd := &m.CreateLoginAttemptCommand{} | ||||||
|  | 
 | ||||||
|  | 				bus.AddHandler("test", func(cmd *m.CreateLoginAttemptCommand) error { | ||||||
|  | 					createLoginAttemptCmd = cmd | ||||||
|  | 					return nil | ||||||
|  | 				}) | ||||||
|  | 
 | ||||||
|  | 				saveInvalidLoginAttempt(&LoginUserQuery{ | ||||||
|  | 					Username:  "user", | ||||||
|  | 					Password:  "pwd", | ||||||
|  | 					IpAddress: "192.168.1.1:56433", | ||||||
|  | 				}) | ||||||
|  | 
 | ||||||
|  | 				Convey("it should dispatch command", func() { | ||||||
|  | 					So(createLoginAttemptCmd, ShouldNotBeNil) | ||||||
|  | 					So(createLoginAttemptCmd.Username, ShouldEqual, "user") | ||||||
|  | 					So(createLoginAttemptCmd.IpAddress, ShouldEqual, "192.168.1.1:56433") | ||||||
|  | 				}) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		Convey("Given brute force login protection disabled", func() { | ||||||
|  | 			setting.DisableBruteForceLoginProtection = true | ||||||
|  | 
 | ||||||
|  | 			Convey("When user login attempt count equals max-1 ", func() { | ||||||
|  | 				withLoginAttempts(maxInvalidLoginAttempts - 1) | ||||||
|  | 				err := validateLoginAttempts("user") | ||||||
|  | 
 | ||||||
|  | 				Convey("it should not result in error", func() { | ||||||
|  | 					So(err, ShouldBeNil) | ||||||
|  | 				}) | ||||||
|  | 			}) | ||||||
|  | 
 | ||||||
|  | 			Convey("When user login attempt count equals max ", func() { | ||||||
|  | 				withLoginAttempts(maxInvalidLoginAttempts) | ||||||
|  | 				err := validateLoginAttempts("user") | ||||||
|  | 
 | ||||||
|  | 				Convey("it should not result in error", func() { | ||||||
|  | 					So(err, ShouldBeNil) | ||||||
|  | 				}) | ||||||
|  | 			}) | ||||||
|  | 
 | ||||||
|  | 			Convey("When user login attempt count is greater than max ", func() { | ||||||
|  | 				withLoginAttempts(maxInvalidLoginAttempts + 5) | ||||||
|  | 				err := validateLoginAttempts("user") | ||||||
|  | 
 | ||||||
|  | 				Convey("it should not result in error", func() { | ||||||
|  | 					So(err, ShouldBeNil) | ||||||
|  | 				}) | ||||||
|  | 			}) | ||||||
|  | 
 | ||||||
|  | 			Convey("When saving invalid login attempt", func() { | ||||||
|  | 				defer bus.ClearBusHandlers() | ||||||
|  | 				createLoginAttemptCmd := (*m.CreateLoginAttemptCommand)(nil) | ||||||
|  | 
 | ||||||
|  | 				bus.AddHandler("test", func(cmd *m.CreateLoginAttemptCommand) error { | ||||||
|  | 					createLoginAttemptCmd = cmd | ||||||
|  | 					return nil | ||||||
|  | 				}) | ||||||
|  | 
 | ||||||
|  | 				saveInvalidLoginAttempt(&LoginUserQuery{ | ||||||
|  | 					Username:  "user", | ||||||
|  | 					Password:  "pwd", | ||||||
|  | 					IpAddress: "192.168.1.1:56433", | ||||||
|  | 				}) | ||||||
|  | 
 | ||||||
|  | 				Convey("it should not dispatch command", func() { | ||||||
|  | 					So(createLoginAttemptCmd, ShouldBeNil) | ||||||
|  | 				}) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func withLoginAttempts(loginAttempts int64) { | ||||||
|  | 	bus.AddHandler("test", func(query *m.GetUserLoginAttemptCountQuery) error { | ||||||
|  | 		query.Result = loginAttempts | ||||||
|  | 		return nil | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | @ -0,0 +1,35 @@ | ||||||
|  | package login | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"crypto/subtle" | ||||||
|  | 
 | ||||||
|  | 	"github.com/grafana/grafana/pkg/bus" | ||||||
|  | 	m "github.com/grafana/grafana/pkg/models" | ||||||
|  | 	"github.com/grafana/grafana/pkg/util" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var validatePassword = func(providedPassword string, userPassword string, userSalt string) error { | ||||||
|  | 	passwordHashed := util.EncodePassword(providedPassword, userSalt) | ||||||
|  | 	if subtle.ConstantTimeCompare([]byte(passwordHashed), []byte(userPassword)) != 1 { | ||||||
|  | 		return ErrInvalidCredentials | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | var loginUsingGrafanaDB = func(query *LoginUserQuery) error { | ||||||
|  | 	userQuery := m.GetUserByLoginQuery{LoginOrEmail: query.Username} | ||||||
|  | 
 | ||||||
|  | 	if err := bus.Dispatch(&userQuery); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	user := userQuery.Result | ||||||
|  | 
 | ||||||
|  | 	if err := validatePassword(query.Password, user.Password, user.Salt); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	query.User = user | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | @ -0,0 +1,139 @@ | ||||||
|  | package login | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/grafana/grafana/pkg/bus" | ||||||
|  | 	m "github.com/grafana/grafana/pkg/models" | ||||||
|  | 	. "github.com/smartystreets/goconvey/convey" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func TestGrafanaLogin(t *testing.T) { | ||||||
|  | 	Convey("Login using Grafana DB", t, func() { | ||||||
|  | 		grafanaLoginScenario("When login with non-existing user", func(sc *grafanaLoginScenarioContext) { | ||||||
|  | 			sc.withNonExistingUser() | ||||||
|  | 			err := loginUsingGrafanaDB(sc.loginUserQuery) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should result in user not found error", func() { | ||||||
|  | 				So(err, ShouldEqual, m.ErrUserNotFound) | ||||||
|  | 			}) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should not call password validation", func() { | ||||||
|  | 				So(sc.validatePasswordCalled, ShouldBeFalse) | ||||||
|  | 			}) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should not pupulate user object", func() { | ||||||
|  | 				So(sc.loginUserQuery.User, ShouldBeNil) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		grafanaLoginScenario("When login with invalid credentials", func(sc *grafanaLoginScenarioContext) { | ||||||
|  | 			sc.withInvalidPassword() | ||||||
|  | 			err := loginUsingGrafanaDB(sc.loginUserQuery) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should result in invalid credentials error", func() { | ||||||
|  | 				So(err, ShouldEqual, ErrInvalidCredentials) | ||||||
|  | 			}) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should call password validation", func() { | ||||||
|  | 				So(sc.validatePasswordCalled, ShouldBeTrue) | ||||||
|  | 			}) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should not pupulate user object", func() { | ||||||
|  | 				So(sc.loginUserQuery.User, ShouldBeNil) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		grafanaLoginScenario("When login with valid credentials", func(sc *grafanaLoginScenarioContext) { | ||||||
|  | 			sc.withValidCredentials() | ||||||
|  | 			err := loginUsingGrafanaDB(sc.loginUserQuery) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should not result in error", func() { | ||||||
|  | 				So(err, ShouldBeNil) | ||||||
|  | 			}) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should call password validation", func() { | ||||||
|  | 				So(sc.validatePasswordCalled, ShouldBeTrue) | ||||||
|  | 			}) | ||||||
|  | 
 | ||||||
|  | 			Convey("it should pupulate user object", func() { | ||||||
|  | 				So(sc.loginUserQuery.User, ShouldNotBeNil) | ||||||
|  | 				So(sc.loginUserQuery.User.Login, ShouldEqual, sc.loginUserQuery.Username) | ||||||
|  | 				So(sc.loginUserQuery.User.Password, ShouldEqual, sc.loginUserQuery.Password) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type grafanaLoginScenarioContext struct { | ||||||
|  | 	loginUserQuery         *LoginUserQuery | ||||||
|  | 	validatePasswordCalled bool | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type grafanaLoginScenarioFunc func(c *grafanaLoginScenarioContext) | ||||||
|  | 
 | ||||||
|  | func grafanaLoginScenario(desc string, fn grafanaLoginScenarioFunc) { | ||||||
|  | 	Convey(desc, func() { | ||||||
|  | 		origValidatePassword := validatePassword | ||||||
|  | 
 | ||||||
|  | 		sc := &grafanaLoginScenarioContext{ | ||||||
|  | 			loginUserQuery: &LoginUserQuery{ | ||||||
|  | 				Username:  "user", | ||||||
|  | 				Password:  "pwd", | ||||||
|  | 				IpAddress: "192.168.1.1:56433", | ||||||
|  | 			}, | ||||||
|  | 			validatePasswordCalled: false, | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		defer func() { | ||||||
|  | 			validatePassword = origValidatePassword | ||||||
|  | 		}() | ||||||
|  | 
 | ||||||
|  | 		fn(sc) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func mockPasswordValidation(valid bool, sc *grafanaLoginScenarioContext) { | ||||||
|  | 	validatePassword = func(providedPassword string, userPassword string, userSalt string) error { | ||||||
|  | 		sc.validatePasswordCalled = true | ||||||
|  | 
 | ||||||
|  | 		if !valid { | ||||||
|  | 			return ErrInvalidCredentials | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (sc *grafanaLoginScenarioContext) getUserByLoginQueryReturns(user *m.User) { | ||||||
|  | 	bus.AddHandler("test", func(query *m.GetUserByLoginQuery) error { | ||||||
|  | 		if user == nil { | ||||||
|  | 			return m.ErrUserNotFound | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		query.Result = user | ||||||
|  | 		return nil | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (sc *grafanaLoginScenarioContext) withValidCredentials() { | ||||||
|  | 	sc.getUserByLoginQueryReturns(&m.User{ | ||||||
|  | 		Id:       1, | ||||||
|  | 		Login:    sc.loginUserQuery.Username, | ||||||
|  | 		Password: sc.loginUserQuery.Password, | ||||||
|  | 		Salt:     "salt", | ||||||
|  | 	}) | ||||||
|  | 	mockPasswordValidation(true, sc) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (sc *grafanaLoginScenarioContext) withNonExistingUser() { | ||||||
|  | 	sc.getUserByLoginQueryReturns(nil) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (sc *grafanaLoginScenarioContext) withInvalidPassword() { | ||||||
|  | 	sc.getUserByLoginQueryReturns(&m.User{ | ||||||
|  | 		Password: sc.loginUserQuery.Password, | ||||||
|  | 		Salt:     "salt", | ||||||
|  | 	}) | ||||||
|  | 	mockPasswordValidation(false, sc) | ||||||
|  | } | ||||||
|  | @ -0,0 +1,21 @@ | ||||||
|  | package login | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"github.com/grafana/grafana/pkg/setting" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var loginUsingLdap = func(query *LoginUserQuery) (bool, error) { | ||||||
|  | 	if !setting.LdapEnabled { | ||||||
|  | 		return false, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	for _, server := range LdapCfg.Servers { | ||||||
|  | 		author := NewLdapAuthenticator(server) | ||||||
|  | 		err := author.Login(query) | ||||||
|  | 		if err == nil || err != ErrInvalidCredentials { | ||||||
|  | 			return true, err | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return true, ErrInvalidCredentials | ||||||
|  | } | ||||||
|  | @ -0,0 +1,172 @@ | ||||||
|  | package login | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	m "github.com/grafana/grafana/pkg/models" | ||||||
|  | 	"github.com/grafana/grafana/pkg/setting" | ||||||
|  | 	. "github.com/smartystreets/goconvey/convey" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func TestLdapLogin(t *testing.T) { | ||||||
|  | 	Convey("Login using ldap", t, func() { | ||||||
|  | 		Convey("Given ldap enabled and a server configured", func() { | ||||||
|  | 			setting.LdapEnabled = true | ||||||
|  | 			LdapCfg.Servers = append(LdapCfg.Servers, | ||||||
|  | 				&LdapServerConf{ | ||||||
|  | 					Host: "", | ||||||
|  | 				}) | ||||||
|  | 
 | ||||||
|  | 			ldapLoginScenario("When login with invalid credentials", func(sc *ldapLoginScenarioContext) { | ||||||
|  | 				sc.withLoginResult(false) | ||||||
|  | 				enabled, err := loginUsingLdap(sc.loginUserQuery) | ||||||
|  | 
 | ||||||
|  | 				Convey("it should return true", func() { | ||||||
|  | 					So(enabled, ShouldBeTrue) | ||||||
|  | 				}) | ||||||
|  | 
 | ||||||
|  | 				Convey("it should return invalid credentials error", func() { | ||||||
|  | 					So(err, ShouldEqual, ErrInvalidCredentials) | ||||||
|  | 				}) | ||||||
|  | 
 | ||||||
|  | 				Convey("it should call ldap login", func() { | ||||||
|  | 					So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeTrue) | ||||||
|  | 				}) | ||||||
|  | 			}) | ||||||
|  | 
 | ||||||
|  | 			ldapLoginScenario("When login with valid credentials", func(sc *ldapLoginScenarioContext) { | ||||||
|  | 				sc.withLoginResult(true) | ||||||
|  | 				enabled, err := loginUsingLdap(sc.loginUserQuery) | ||||||
|  | 
 | ||||||
|  | 				Convey("it should return true", func() { | ||||||
|  | 					So(enabled, ShouldBeTrue) | ||||||
|  | 				}) | ||||||
|  | 
 | ||||||
|  | 				Convey("it should not return error", func() { | ||||||
|  | 					So(err, ShouldBeNil) | ||||||
|  | 				}) | ||||||
|  | 
 | ||||||
|  | 				Convey("it should call ldap login", func() { | ||||||
|  | 					So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeTrue) | ||||||
|  | 				}) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		Convey("Given ldap enabled and no server configured", func() { | ||||||
|  | 			setting.LdapEnabled = true | ||||||
|  | 			LdapCfg.Servers = make([]*LdapServerConf, 0) | ||||||
|  | 
 | ||||||
|  | 			ldapLoginScenario("When login", func(sc *ldapLoginScenarioContext) { | ||||||
|  | 				sc.withLoginResult(true) | ||||||
|  | 				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 not call ldap login", func() { | ||||||
|  | 					So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeFalse) | ||||||
|  | 				}) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		Convey("Given ldap disabled", func() { | ||||||
|  | 			setting.LdapEnabled = false | ||||||
|  | 
 | ||||||
|  | 			ldapLoginScenario("When login", func(sc *ldapLoginScenarioContext) { | ||||||
|  | 				sc.withLoginResult(false) | ||||||
|  | 				enabled, err := loginUsingLdap(&LoginUserQuery{ | ||||||
|  | 					Username: "user", | ||||||
|  | 					Password: "pwd", | ||||||
|  | 				}) | ||||||
|  | 
 | ||||||
|  | 				Convey("it should return false", func() { | ||||||
|  | 					So(enabled, ShouldBeFalse) | ||||||
|  | 				}) | ||||||
|  | 
 | ||||||
|  | 				Convey("it should not return error", func() { | ||||||
|  | 					So(err, ShouldBeNil) | ||||||
|  | 				}) | ||||||
|  | 
 | ||||||
|  | 				Convey("it should not call ldap login", func() { | ||||||
|  | 					So(sc.ldapAuthenticatorMock.loginCalled, ShouldBeFalse) | ||||||
|  | 				}) | ||||||
|  | 			}) | ||||||
|  | 		}) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func mockLdapAuthenticator(valid bool) *mockLdapAuther { | ||||||
|  | 	mock := &mockLdapAuther{ | ||||||
|  | 		validLogin: valid, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	NewLdapAuthenticator = func(server *LdapServerConf) ILdapAuther { | ||||||
|  | 		return mock | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return mock | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type mockLdapAuther struct { | ||||||
|  | 	validLogin  bool | ||||||
|  | 	loginCalled bool | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *mockLdapAuther) Login(query *LoginUserQuery) error { | ||||||
|  | 	a.loginCalled = true | ||||||
|  | 
 | ||||||
|  | 	if !a.validLogin { | ||||||
|  | 		return ErrInvalidCredentials | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *mockLdapAuther) SyncSignedInUser(signedInUser *m.SignedInUser) error { | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *mockLdapAuther) GetGrafanaUserFor(ldapUser *LdapUserInfo) (*m.User, error) { | ||||||
|  | 	return nil, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (a *mockLdapAuther) SyncOrgRoles(user *m.User, ldapUser *LdapUserInfo) error { | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type ldapLoginScenarioContext struct { | ||||||
|  | 	loginUserQuery        *LoginUserQuery | ||||||
|  | 	ldapAuthenticatorMock *mockLdapAuther | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type ldapLoginScenarioFunc func(c *ldapLoginScenarioContext) | ||||||
|  | 
 | ||||||
|  | func ldapLoginScenario(desc string, fn ldapLoginScenarioFunc) { | ||||||
|  | 	Convey(desc, func() { | ||||||
|  | 		origNewLdapAuthenticator := NewLdapAuthenticator | ||||||
|  | 
 | ||||||
|  | 		sc := &ldapLoginScenarioContext{ | ||||||
|  | 			loginUserQuery: &LoginUserQuery{ | ||||||
|  | 				Username:  "user", | ||||||
|  | 				Password:  "pwd", | ||||||
|  | 				IpAddress: "192.168.1.1:56433", | ||||||
|  | 			}, | ||||||
|  | 			ldapAuthenticatorMock: &mockLdapAuther{}, | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		defer func() { | ||||||
|  | 			NewLdapAuthenticator = origNewLdapAuthenticator | ||||||
|  | 		}() | ||||||
|  | 
 | ||||||
|  | 		fn(sc) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (sc *ldapLoginScenarioContext) withLoginResult(valid bool) { | ||||||
|  | 	sc.ldapAuthenticatorMock = mockLdapAuthenticator(valid) | ||||||
|  | } | ||||||
|  | @ -0,0 +1,36 @@ | ||||||
|  | package models | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"time" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type LoginAttempt struct { | ||||||
|  | 	Id        int64 | ||||||
|  | 	Username  string | ||||||
|  | 	IpAddress string | ||||||
|  | 	Created   time.Time | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // ---------------------
 | ||||||
|  | // COMMANDS
 | ||||||
|  | 
 | ||||||
|  | type CreateLoginAttemptCommand struct { | ||||||
|  | 	Username  string | ||||||
|  | 	IpAddress string | ||||||
|  | 
 | ||||||
|  | 	Result LoginAttempt | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type DeleteOldLoginAttemptsCommand struct { | ||||||
|  | 	OlderThan   time.Time | ||||||
|  | 	DeletedRows int64 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // ---------------------
 | ||||||
|  | // QUERIES
 | ||||||
|  | 
 | ||||||
|  | type GetUserLoginAttemptCountQuery struct { | ||||||
|  | 	Username string | ||||||
|  | 	Since    time.Time | ||||||
|  | 	Result   int64 | ||||||
|  | } | ||||||
|  | @ -46,6 +46,7 @@ func (service *CleanUpService) start(ctx context.Context) error { | ||||||
| 			service.cleanUpTmpFiles() | 			service.cleanUpTmpFiles() | ||||||
| 			service.deleteExpiredSnapshots() | 			service.deleteExpiredSnapshots() | ||||||
| 			service.deleteExpiredDashboardVersions() | 			service.deleteExpiredDashboardVersions() | ||||||
|  | 			service.deleteOldLoginAttempts() | ||||||
| 		case <-ctx.Done(): | 		case <-ctx.Done(): | ||||||
| 			return ctx.Err() | 			return ctx.Err() | ||||||
| 		} | 		} | ||||||
|  | @ -88,3 +89,18 @@ func (service *CleanUpService) deleteExpiredSnapshots() { | ||||||
| func (service *CleanUpService) deleteExpiredDashboardVersions() { | func (service *CleanUpService) deleteExpiredDashboardVersions() { | ||||||
| 	bus.Dispatch(&m.DeleteExpiredVersionsCommand{}) | 	bus.Dispatch(&m.DeleteExpiredVersionsCommand{}) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | func (service *CleanUpService) deleteOldLoginAttempts() { | ||||||
|  | 	if setting.DisableBruteForceLoginProtection { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	cmd := m.DeleteOldLoginAttemptsCommand{ | ||||||
|  | 		OlderThan: time.Now().Add(time.Minute * -10), | ||||||
|  | 	} | ||||||
|  | 	if err := bus.Dispatch(&cmd); err != nil { | ||||||
|  | 		service.log.Error("Problem deleting expired login attempts", "error", err.Error()) | ||||||
|  | 	} else { | ||||||
|  | 		service.log.Debug("Deleted expired login attempts", "rows affected", cmd.DeletedRows) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -0,0 +1,91 @@ | ||||||
|  | package sqlstore | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"strconv" | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"github.com/grafana/grafana/pkg/bus" | ||||||
|  | 	m "github.com/grafana/grafana/pkg/models" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var getTimeNow = time.Now | ||||||
|  | 
 | ||||||
|  | func init() { | ||||||
|  | 	bus.AddHandler("sql", CreateLoginAttempt) | ||||||
|  | 	bus.AddHandler("sql", DeleteOldLoginAttempts) | ||||||
|  | 	bus.AddHandler("sql", GetUserLoginAttemptCount) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func CreateLoginAttempt(cmd *m.CreateLoginAttemptCommand) error { | ||||||
|  | 	return inTransaction(func(sess *DBSession) error { | ||||||
|  | 		loginAttempt := m.LoginAttempt{ | ||||||
|  | 			Username:  cmd.Username, | ||||||
|  | 			IpAddress: cmd.IpAddress, | ||||||
|  | 			Created:   getTimeNow(), | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if _, err := sess.Insert(&loginAttempt); err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		cmd.Result = loginAttempt | ||||||
|  | 
 | ||||||
|  | 		return nil | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func DeleteOldLoginAttempts(cmd *m.DeleteOldLoginAttemptsCommand) error { | ||||||
|  | 	return inTransaction(func(sess *DBSession) error { | ||||||
|  | 		var maxId int64 | ||||||
|  | 		sql := "SELECT max(id) as id FROM login_attempt WHERE created < " + dialect.DateTimeFunc("?") | ||||||
|  | 		result, err := sess.Query(sql, cmd.OlderThan) | ||||||
|  | 
 | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		maxId = toInt64(result[0]["id"]) | ||||||
|  | 
 | ||||||
|  | 		if maxId == 0 { | ||||||
|  | 			return nil | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		sql = "DELETE FROM login_attempt WHERE id <= ?" | ||||||
|  | 
 | ||||||
|  | 		if result, err := sess.Exec(sql, maxId); err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} else if cmd.DeletedRows, err = result.RowsAffected(); err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		return nil | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func GetUserLoginAttemptCount(query *m.GetUserLoginAttemptCountQuery) error { | ||||||
|  | 	loginAttempt := new(m.LoginAttempt) | ||||||
|  | 	total, err := x. | ||||||
|  | 		Where("username = ?", query.Username). | ||||||
|  | 		And("created >="+dialect.DateTimeFunc("?"), query.Since). | ||||||
|  | 		Count(loginAttempt) | ||||||
|  | 
 | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	query.Result = total | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func toInt64(i interface{}) int64 { | ||||||
|  | 	switch i.(type) { | ||||||
|  | 	case []byte: | ||||||
|  | 		n, _ := strconv.ParseInt(string(i.([]byte)), 10, 64) | ||||||
|  | 		return n | ||||||
|  | 	case int: | ||||||
|  | 		return int64(i.(int)) | ||||||
|  | 	case int64: | ||||||
|  | 		return i.(int64) | ||||||
|  | 	} | ||||||
|  | 	return 0 | ||||||
|  | } | ||||||
|  | @ -0,0 +1,125 @@ | ||||||
|  | package sqlstore | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"testing" | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	m "github.com/grafana/grafana/pkg/models" | ||||||
|  | 	. "github.com/smartystreets/goconvey/convey" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func mockTime(mock time.Time) time.Time { | ||||||
|  | 	getTimeNow = func() time.Time { return mock } | ||||||
|  | 	return mock | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestLoginAttempts(t *testing.T) { | ||||||
|  | 	Convey("Testing Login Attempts DB Access", t, func() { | ||||||
|  | 		InitTestDB(t) | ||||||
|  | 
 | ||||||
|  | 		user := "user" | ||||||
|  | 		beginningOfTime := mockTime(time.Date(2017, 10, 22, 8, 0, 0, 0, time.Local)) | ||||||
|  | 
 | ||||||
|  | 		err := CreateLoginAttempt(&m.CreateLoginAttemptCommand{ | ||||||
|  | 			Username:  user, | ||||||
|  | 			IpAddress: "192.168.0.1", | ||||||
|  | 		}) | ||||||
|  | 		So(err, ShouldBeNil) | ||||||
|  | 
 | ||||||
|  | 		timePlusOneMinute := mockTime(beginningOfTime.Add(time.Minute * 1)) | ||||||
|  | 
 | ||||||
|  | 		err = CreateLoginAttempt(&m.CreateLoginAttemptCommand{ | ||||||
|  | 			Username:  user, | ||||||
|  | 			IpAddress: "192.168.0.1", | ||||||
|  | 		}) | ||||||
|  | 		So(err, ShouldBeNil) | ||||||
|  | 
 | ||||||
|  | 		timePlusTwoMinutes := mockTime(beginningOfTime.Add(time.Minute * 2)) | ||||||
|  | 
 | ||||||
|  | 		err = CreateLoginAttempt(&m.CreateLoginAttemptCommand{ | ||||||
|  | 			Username:  user, | ||||||
|  | 			IpAddress: "192.168.0.1", | ||||||
|  | 		}) | ||||||
|  | 		So(err, ShouldBeNil) | ||||||
|  | 
 | ||||||
|  | 		Convey("Should return a total count of zero login attempts when comparing since beginning of time + 2min and 1s", func() { | ||||||
|  | 			query := m.GetUserLoginAttemptCountQuery{ | ||||||
|  | 				Username: user, | ||||||
|  | 				Since:    timePlusTwoMinutes.Add(time.Second * 1), | ||||||
|  | 			} | ||||||
|  | 			err := GetUserLoginAttemptCount(&query) | ||||||
|  | 			So(err, ShouldBeNil) | ||||||
|  | 			So(query.Result, ShouldEqual, 0) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		Convey("Should return the total count of login attempts since beginning of time", func() { | ||||||
|  | 			query := m.GetUserLoginAttemptCountQuery{ | ||||||
|  | 				Username: user, | ||||||
|  | 				Since:    beginningOfTime, | ||||||
|  | 			} | ||||||
|  | 			err := GetUserLoginAttemptCount(&query) | ||||||
|  | 			So(err, ShouldBeNil) | ||||||
|  | 			So(query.Result, ShouldEqual, 3) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		Convey("Should return the total count of login attempts since beginning of time + 1min", func() { | ||||||
|  | 			query := m.GetUserLoginAttemptCountQuery{ | ||||||
|  | 				Username: user, | ||||||
|  | 				Since:    timePlusOneMinute, | ||||||
|  | 			} | ||||||
|  | 			err := GetUserLoginAttemptCount(&query) | ||||||
|  | 			So(err, ShouldBeNil) | ||||||
|  | 			So(query.Result, ShouldEqual, 2) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		Convey("Should return the total count of login attempts since beginning of time + 2min", func() { | ||||||
|  | 			query := m.GetUserLoginAttemptCountQuery{ | ||||||
|  | 				Username: user, | ||||||
|  | 				Since:    timePlusTwoMinutes, | ||||||
|  | 			} | ||||||
|  | 			err := GetUserLoginAttemptCount(&query) | ||||||
|  | 			So(err, ShouldBeNil) | ||||||
|  | 			So(query.Result, ShouldEqual, 1) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		Convey("Should return deleted rows older than beginning of time", func() { | ||||||
|  | 			cmd := m.DeleteOldLoginAttemptsCommand{ | ||||||
|  | 				OlderThan: beginningOfTime, | ||||||
|  | 			} | ||||||
|  | 			err := DeleteOldLoginAttempts(&cmd) | ||||||
|  | 
 | ||||||
|  | 			So(err, ShouldBeNil) | ||||||
|  | 			So(cmd.DeletedRows, ShouldEqual, 0) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		Convey("Should return deleted rows older than beginning of time + 1min", func() { | ||||||
|  | 			cmd := m.DeleteOldLoginAttemptsCommand{ | ||||||
|  | 				OlderThan: timePlusOneMinute, | ||||||
|  | 			} | ||||||
|  | 			err := DeleteOldLoginAttempts(&cmd) | ||||||
|  | 
 | ||||||
|  | 			So(err, ShouldBeNil) | ||||||
|  | 			So(cmd.DeletedRows, ShouldEqual, 1) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		Convey("Should return deleted rows older than beginning of time + 2min", func() { | ||||||
|  | 			cmd := m.DeleteOldLoginAttemptsCommand{ | ||||||
|  | 				OlderThan: timePlusTwoMinutes, | ||||||
|  | 			} | ||||||
|  | 			err := DeleteOldLoginAttempts(&cmd) | ||||||
|  | 
 | ||||||
|  | 			So(err, ShouldBeNil) | ||||||
|  | 			So(cmd.DeletedRows, ShouldEqual, 2) | ||||||
|  | 		}) | ||||||
|  | 
 | ||||||
|  | 		Convey("Should return deleted rows older than beginning of time + 2min and 1s", func() { | ||||||
|  | 			cmd := m.DeleteOldLoginAttemptsCommand{ | ||||||
|  | 				OlderThan: timePlusTwoMinutes.Add(time.Second * 1), | ||||||
|  | 			} | ||||||
|  | 			err := DeleteOldLoginAttempts(&cmd) | ||||||
|  | 
 | ||||||
|  | 			So(err, ShouldBeNil) | ||||||
|  | 			So(cmd.DeletedRows, ShouldEqual, 3) | ||||||
|  | 		}) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | @ -0,0 +1,23 @@ | ||||||
|  | package migrations | ||||||
|  | 
 | ||||||
|  | import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" | ||||||
|  | 
 | ||||||
|  | func addLoginAttemptMigrations(mg *Migrator) { | ||||||
|  | 	loginAttemptV1 := Table{ | ||||||
|  | 		Name: "login_attempt", | ||||||
|  | 		Columns: []*Column{ | ||||||
|  | 			{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, | ||||||
|  | 			{Name: "username", Type: DB_NVarchar, Length: 190, Nullable: false}, | ||||||
|  | 			{Name: "ip_address", Type: DB_NVarchar, Length: 30, Nullable: false}, | ||||||
|  | 			{Name: "created", Type: DB_DateTime, Nullable: false}, | ||||||
|  | 		}, | ||||||
|  | 		Indices: []*Index{ | ||||||
|  | 			{Cols: []string{"username"}}, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// create table
 | ||||||
|  | 	mg.AddMigration("create login attempt table", NewAddTableMigration(loginAttemptV1)) | ||||||
|  | 	// add indices
 | ||||||
|  | 	mg.AddMigration("add index login_attempt.username", NewAddIndexMigration(loginAttemptV1, loginAttemptV1.Indices[0])) | ||||||
|  | } | ||||||
|  | @ -29,6 +29,7 @@ func AddMigrations(mg *Migrator) { | ||||||
| 	addTeamMigrations(mg) | 	addTeamMigrations(mg) | ||||||
| 	addDashboardAclMigrations(mg) | 	addDashboardAclMigrations(mg) | ||||||
| 	addTagMigration(mg) | 	addTagMigration(mg) | ||||||
|  | 	addLoginAttemptMigrations(mg) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func addMigrationLogMigrations(mg *Migrator) { | func addMigrationLogMigrations(mg *Migrator) { | ||||||
|  |  | ||||||
|  | @ -19,6 +19,7 @@ type Dialect interface { | ||||||
| 	LikeStr() string | 	LikeStr() string | ||||||
| 	Default(col *Column) string | 	Default(col *Column) string | ||||||
| 	BooleanStr(bool) string | 	BooleanStr(bool) string | ||||||
|  | 	DateTimeFunc(string) string | ||||||
| 
 | 
 | ||||||
| 	CreateIndexSql(tableName string, index *Index) string | 	CreateIndexSql(tableName string, index *Index) string | ||||||
| 	CreateTableSql(table *Table) string | 	CreateTableSql(table *Table) string | ||||||
|  | @ -78,6 +79,10 @@ func (b *BaseDialect) Default(col *Column) string { | ||||||
| 	return col.Default | 	return col.Default | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (db *BaseDialect) DateTimeFunc(value string) string { | ||||||
|  | 	return value | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (b *BaseDialect) CreateTableSql(table *Table) string { | func (b *BaseDialect) CreateTableSql(table *Table) string { | ||||||
| 	var sql string | 	var sql string | ||||||
| 	sql = "CREATE TABLE IF NOT EXISTS " | 	sql = "CREATE TABLE IF NOT EXISTS " | ||||||
|  |  | ||||||
|  | @ -36,6 +36,10 @@ func (db *Sqlite3) BooleanStr(value bool) string { | ||||||
| 	return "0" | 	return "0" | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (db *Sqlite3) DateTimeFunc(value string) string { | ||||||
|  | 	return "datetime(" + value + ")" | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (db *Sqlite3) SqlType(c *Column) string { | func (db *Sqlite3) SqlType(c *Column) string { | ||||||
| 	switch c.Type { | 	switch c.Type { | ||||||
| 	case DB_Date, DB_DateTime, DB_TimeStamp, DB_Time: | 	case DB_Date, DB_DateTime, DB_TimeStamp, DB_Time: | ||||||
|  |  | ||||||
|  | @ -12,7 +12,7 @@ type TestDB struct { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| var TestDB_Sqlite3 = TestDB{DriverName: "sqlite3", ConnStr: ":memory:?_loc=Local"} | var TestDB_Sqlite3 = TestDB{DriverName: "sqlite3", ConnStr: ":memory:?_loc=Local"} | ||||||
| var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?collation=utf8mb4_unicode_ci"} | var TestDB_Mysql = TestDB{DriverName: "mysql", ConnStr: "grafana:password@tcp(localhost:3306)/grafana_tests?collation=utf8mb4_unicode_ci&loc=Local"} | ||||||
| var TestDB_Postgres = TestDB{DriverName: "postgres", ConnStr: "user=grafanatest password=grafanatest host=localhost port=5432 dbname=grafanatest sslmode=disable"} | var TestDB_Postgres = TestDB{DriverName: "postgres", ConnStr: "user=grafanatest password=grafanatest host=localhost port=5432 dbname=grafanatest sslmode=disable"} | ||||||
| 
 | 
 | ||||||
| func CleanDB(x *xorm.Engine) { | func CleanDB(x *xorm.Engine) { | ||||||
|  |  | ||||||
|  | @ -75,13 +75,14 @@ var ( | ||||||
| 	EnforceDomain      bool | 	EnforceDomain      bool | ||||||
| 
 | 
 | ||||||
| 	// Security settings.
 | 	// Security settings.
 | ||||||
| 	SecretKey             string | 	SecretKey                        string | ||||||
| 	LogInRememberDays     int | 	LogInRememberDays                int | ||||||
| 	CookieUserName        string | 	CookieUserName                   string | ||||||
| 	CookieRememberName    string | 	CookieRememberName               string | ||||||
| 	DisableGravatar       bool | 	DisableGravatar                  bool | ||||||
| 	EmailCodeValidMinutes int | 	EmailCodeValidMinutes            int | ||||||
| 	DataProxyWhiteList    map[string]bool | 	DataProxyWhiteList               map[string]bool | ||||||
|  | 	DisableBruteForceLoginProtection bool | ||||||
| 
 | 
 | ||||||
| 	// Snapshots
 | 	// Snapshots
 | ||||||
| 	ExternalSnapshotUrl   string | 	ExternalSnapshotUrl   string | ||||||
|  | @ -514,6 +515,7 @@ func NewConfigContext(args *CommandLineArgs) error { | ||||||
| 	CookieUserName = security.Key("cookie_username").String() | 	CookieUserName = security.Key("cookie_username").String() | ||||||
| 	CookieRememberName = security.Key("cookie_remember_name").String() | 	CookieRememberName = security.Key("cookie_remember_name").String() | ||||||
| 	DisableGravatar = security.Key("disable_gravatar").MustBool(true) | 	DisableGravatar = security.Key("disable_gravatar").MustBool(true) | ||||||
|  | 	DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false) | ||||||
| 
 | 
 | ||||||
| 	// read snapshots settings
 | 	// read snapshots settings
 | ||||||
| 	snapshots := Cfg.Section("snapshots") | 	snapshots := Cfg.Section("snapshots") | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue