Jelajahi Sumber

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
Marcus Efraimsson 8 tahun lalu
induk
melakukan
3d1c624c12

+ 3 - 0
conf/defaults.ini

@@ -174,6 +174,9 @@ disable_gravatar = false
 # data source proxy whitelist (ip_or_domain:port separated by spaces)
 data_source_proxy_whitelist =
 
+# disable protection against brute force login attempts
+disable_brute_force_login_protection = false
+
 #################################### Snapshots ###########################
 [snapshots]
 # snapshot sharing options

+ 3 - 0
conf/sample.ini

@@ -162,6 +162,9 @@ log_queries =
 # data source proxy whitelist (ip_or_domain:port separated by spaces)
 ;data_source_proxy_whitelist =
 
+# disable protection against brute force login attempts
+;disable_brute_force_login_protection = false
+
 #################################### Snapshots ###########################
 [snapshots]
 # snapshot sharing options

+ 4 - 3
pkg/api/login.go

@@ -102,12 +102,13 @@ func LoginPost(c *middleware.Context, cmd dtos.LoginCommand) Response {
 	}
 
 	authQuery := login.LoginUserQuery{
-		Username: cmd.User,
-		Password: cmd.Password,
+		Username:  cmd.User,
+		Password:  cmd.Password,
+		IpAddress: c.Req.RemoteAddr,
 	}
 
 	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)
 		}
 

+ 21 - 32
pkg/login/auth.go

@@ -3,21 +3,20 @@ package login
 import (
 	"errors"
 
-	"crypto/subtle"
 	"github.com/grafana/grafana/pkg/bus"
 	m "github.com/grafana/grafana/pkg/models"
-	"github.com/grafana/grafana/pkg/setting"
-	"github.com/grafana/grafana/pkg/util"
 )
 
 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 {
-	Username string
-	Password string
-	User     *m.User
+	Username  string
+	Password  string
+	User      *m.User
+	IpAddress string
 }
 
 func Init() {
@@ -26,41 +25,31 @@ func Init() {
 }
 
 func AuthenticateUser(query *LoginUserQuery) error {
-	err := loginUsingGrafanaDB(query)
-	if err == nil || err != ErrInvalidCredentials {
+	if err := validateLoginAttempts(query.Username); err != nil {
 		return err
 	}
 
-	if setting.LdapEnabled {
-		for _, server := range LdapCfg.Servers {
-			author := NewLdapAuthenticator(server)
-			err = author.Login(query)
-			if err == nil || err != ErrInvalidCredentials {
-				return err
-			}
-		}
+	err := loginUsingGrafanaDB(query)
+	if err == nil || (err != m.ErrUserNotFound && err != 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
+	ldapEnabled, ldapErr := loginUsingLdap(query)
+	if ldapEnabled {
+		if ldapErr == nil || ldapErr != ErrInvalidCredentials {
+			return ldapErr
 		}
-		return err
+
+		err = ldapErr
 	}
 
-	user := userQuery.Result
+	if err == ErrInvalidCredentials {
+		saveInvalidLoginAttempt(query)
+	}
 
-	passwordHashed := util.EncodePassword(query.Password, user.Salt)
-	if subtle.ConstantTimeCompare([]byte(passwordHashed), []byte(user.Password)) != 1 {
+	if err == m.ErrUserNotFound {
 		return ErrInvalidCredentials
 	}
 
-	query.User = user
-	return nil
+	return err
 }

+ 214 - 0
pkg/login/auth_test.go

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

+ 48 - 0
pkg/login/brute_force_login_protection.go

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

+ 125 - 0
pkg/login/brute_force_login_protection_test.go

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

+ 35 - 0
pkg/login/grafana_login.go

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

+ 139 - 0
pkg/login/grafana_login_test.go

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

+ 21 - 0
pkg/login/ldap_login.go

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

+ 172 - 0
pkg/login/ldap_login_test.go

@@ -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
pkg/login/settings.go → pkg/login/ldap_settings.go


+ 36 - 0
pkg/models/login_attempt.go

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

+ 16 - 0
pkg/services/cleanup/cleanup.go

@@ -46,6 +46,7 @@ func (service *CleanUpService) start(ctx context.Context) error {
 			service.cleanUpTmpFiles()
 			service.deleteExpiredSnapshots()
 			service.deleteExpiredDashboardVersions()
+			service.deleteOldLoginAttempts()
 		case <-ctx.Done():
 			return ctx.Err()
 		}
@@ -88,3 +89,18 @@ func (service *CleanUpService) deleteExpiredSnapshots() {
 func (service *CleanUpService) deleteExpiredDashboardVersions() {
 	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)
+	}
+}

+ 91 - 0
pkg/services/sqlstore/login_attempt.go

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

+ 125 - 0
pkg/services/sqlstore/login_attempt_test.go

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

+ 23 - 0
pkg/services/sqlstore/migrations/login_attempt_mig.go

@@ -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]))
+}

+ 1 - 0
pkg/services/sqlstore/migrations/migrations.go

@@ -29,6 +29,7 @@ func AddMigrations(mg *Migrator) {
 	addTeamMigrations(mg)
 	addDashboardAclMigrations(mg)
 	addTagMigration(mg)
+	addLoginAttemptMigrations(mg)
 }
 
 func addMigrationLogMigrations(mg *Migrator) {

+ 5 - 0
pkg/services/sqlstore/migrator/dialect.go

@@ -19,6 +19,7 @@ type Dialect interface {
 	LikeStr() string
 	Default(col *Column) string
 	BooleanStr(bool) string
+	DateTimeFunc(string) string
 
 	CreateIndexSql(tableName string, index *Index) string
 	CreateTableSql(table *Table) string
@@ -78,6 +79,10 @@ func (b *BaseDialect) Default(col *Column) string {
 	return col.Default
 }
 
+func (db *BaseDialect) DateTimeFunc(value string) string {
+	return value
+}
+
 func (b *BaseDialect) CreateTableSql(table *Table) string {
 	var sql string
 	sql = "CREATE TABLE IF NOT EXISTS "

+ 4 - 0
pkg/services/sqlstore/migrator/sqlite_dialect.go

@@ -36,6 +36,10 @@ func (db *Sqlite3) BooleanStr(value bool) string {
 	return "0"
 }
 
+func (db *Sqlite3) DateTimeFunc(value string) string {
+	return "datetime(" + value + ")"
+}
+
 func (db *Sqlite3) SqlType(c *Column) string {
 	switch c.Type {
 	case DB_Date, DB_DateTime, DB_TimeStamp, DB_Time:

+ 1 - 1
pkg/services/sqlstore/sqlutil/sqlutil.go

@@ -12,7 +12,7 @@ type TestDB struct {
 }
 
 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"}
 
 func CleanDB(x *xorm.Engine) {

+ 9 - 7
pkg/setting/setting.go

@@ -75,13 +75,14 @@ var (
 	EnforceDomain      bool
 
 	// Security settings.
-	SecretKey             string
-	LogInRememberDays     int
-	CookieUserName        string
-	CookieRememberName    string
-	DisableGravatar       bool
-	EmailCodeValidMinutes int
-	DataProxyWhiteList    map[string]bool
+	SecretKey                        string
+	LogInRememberDays                int
+	CookieUserName                   string
+	CookieRememberName               string
+	DisableGravatar                  bool
+	EmailCodeValidMinutes            int
+	DataProxyWhiteList               map[string]bool
+	DisableBruteForceLoginProtection bool
 
 	// Snapshots
 	ExternalSnapshotUrl   string
@@ -514,6 +515,7 @@ func NewConfigContext(args *CommandLineArgs) error {
 	CookieUserName = security.Key("cookie_username").String()
 	CookieRememberName = security.Key("cookie_remember_name").String()
 	DisableGravatar = security.Key("disable_gravatar").MustBool(true)
+	DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false)
 
 	// read snapshots settings
 	snapshots := Cfg.Section("snapshots")