mirror of https://github.com/grafana/grafana
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 #7616pull/10635/head
parent
475febd004
commit
3d1c624c12
@ -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 |
||||||
|
} |
||||||
@ -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])) |
||||||
|
} |
||||||
Loading…
Reference in new issue