mirror of
https://github.com/grafana/grafana.git
synced 2025-09-22 21:14:35 +08:00
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:

committed by
Torkel Ödegaard

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
|
|
||||||
}
|
|
||||||
|
214
pkg/login/auth_test.go
Normal file
214
pkg/login/auth_test.go
Normal file
@ -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
pkg/login/brute_force_login_protection.go
Normal file
48
pkg/login/brute_force_login_protection.go
Normal file
@ -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
pkg/login/brute_force_login_protection_test.go
Normal file
125
pkg/login/brute_force_login_protection_test.go
Normal file
@ -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
pkg/login/grafana_login.go
Normal file
35
pkg/login/grafana_login.go
Normal file
@ -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
pkg/login/grafana_login_test.go
Normal file
139
pkg/login/grafana_login_test.go
Normal file
@ -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
pkg/login/ldap_login.go
Normal file
21
pkg/login/ldap_login.go
Normal file
@ -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
pkg/login/ldap_login_test.go
Normal file
172
pkg/login/ldap_login_test.go
Normal file
@ -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)
|
||||||
|
}
|
36
pkg/models/login_attempt.go
Normal file
36
pkg/models/login_attempt.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
91
pkg/services/sqlstore/login_attempt.go
Normal file
91
pkg/services/sqlstore/login_attempt.go
Normal file
@ -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
pkg/services/sqlstore/login_attempt_test.go
Normal file
125
pkg/services/sqlstore/login_attempt_test.go
Normal file
@ -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
pkg/services/sqlstore/migrations/login_attempt_mig.go
Normal file
23
pkg/services/sqlstore/migrations/login_attempt_mig.go
Normal file
@ -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")
|
||||||
|
Reference in New Issue
Block a user