diff --git a/conf/defaults.ini b/conf/defaults.ini index 88f556f0d9c..855f1b90072 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -293,6 +293,9 @@ viewers_can_edit = false # Editors can administrate dashboard, folders and teams they create editors_can_admin = false +# The duration in time a user invitation remains valid before expiring. This setting should be expressed as a duration. Examples: 6h (hours), 2d (days), 1w (week). Default is 24h (24 hours). The minimum supported duration is 15m (15 minutes). +user_invite_max_lifetime_duration = 24h + [auth] # Login cookie name login_cookie_name = grafana_session diff --git a/conf/sample.ini b/conf/sample.ini index 7def40604ef..41c5e96a853 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -292,11 +292,14 @@ # Editors can administrate dashboard, folders and teams they create ;editors_can_admin = false +# The duration in time a user invitation remains valid before expiring. This setting should be expressed as a duration. Examples: 6h (hours), 2d (days), 1w (week). Default is 24h (24 hours). The minimum supported duration is 15m (15 minutes). +;user_invite_max_lifetime_duration = 24h + [auth] # Login cookie name ;login_cookie_name = grafana_session -# The maximum lifetime (duration) an authenticated user can be inactive before being required to login at next visit. Default is 7 days (7d). This setting should be expressed as a duration, e.g. 5m (minutes), 6h (hours), 10d (days), 2w (weeks), 1M (month). The lifetime resets at each successful token rotation +# The maximum lifetime (duration) an authenticated user can be inactive before being required to login at next visit. Default is 7 days (7d). This setting should be expressed as a duration, e.g. 5m (minutes), 6h (hours), 10d (days), 2w (weeks), 1M (month). The lifetime resets at each successful token rotation. ;login_maximum_inactive_lifetime_duration = # The maximum lifetime (duration) an authenticated user can be logged in since login time before being required to login. Default is 30 days (30d). This setting should be expressed as a duration, e.g. 5m (minutes), 6h (hours), 10d (days), 2w (weeks), 1M (month). diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md index 815a62ff026..3d1f56994fc 100644 --- a/docs/sources/administration/configuration.md +++ b/docs/sources/administration/configuration.md @@ -621,6 +621,12 @@ Default is `false`. Editors can administrate dashboards, folders and teams they create. Default is `false`. +### user_invite_max_lifetime_duration + +The duration in time a user invitation remains valid before expiring. +This setting should be expressed as a duration. Examples: 6h (hours), 2d (days), 1w (week). +Default is `24h` (24 hours). The minimum supported duration is `15m` (15 minutes). +
## [auth] diff --git a/pkg/models/temp_user.go b/pkg/models/temp_user.go index 0e09627aab1..13ac06f5fe7 100644 --- a/pkg/models/temp_user.go +++ b/pkg/models/temp_user.go @@ -17,6 +17,7 @@ const ( TmpUserInvitePending TempUserStatus = "InvitePending" TmpUserCompleted TempUserStatus = "Completed" TmpUserRevoked TempUserStatus = "Revoked" + TmpUserExpired TempUserStatus = "Expired" ) // TempUser holds data for org invites and unconfirmed sign ups @@ -35,8 +36,8 @@ type TempUser struct { Code string RemoteAddr string - Created time.Time - Updated time.Time + Created int64 + Updated int64 } // --------------------- @@ -60,6 +61,12 @@ type UpdateTempUserStatusCommand struct { Status TempUserStatus } +type ExpireTempUsersCommand struct { + OlderThan time.Time + + NumExpired int64 +} + type UpdateTempUserWithEmailSentCommand struct { Code string } diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go index f2eb19b6179..ec71b3ad553 100644 --- a/pkg/services/cleanup/cleanup.go +++ b/pkg/services/cleanup/cleanup.go @@ -45,7 +45,7 @@ func (srv *CleanUpService) Run(ctx context.Context) error { srv.deleteExpiredSnapshots() srv.deleteExpiredDashboardVersions() srv.cleanUpOldAnnotations(ctxWithTimeout) - + srv.expireOldUserInvites() err := srv.ServerLockService.LockAndExecute(ctx, "delete old login attempts", time.Minute*10, func() { srv.deleteOldLoginAttempts() @@ -138,3 +138,16 @@ func (srv *CleanUpService) deleteOldLoginAttempts() { srv.log.Debug("Deleted expired login attempts", "rows affected", cmd.DeletedRows) } } + +func (srv *CleanUpService) expireOldUserInvites() { + maxInviteLifetime := srv.Cfg.UserInviteMaxLifetime + + cmd := models.ExpireTempUsersCommand{ + OlderThan: time.Now().Add(-maxInviteLifetime), + } + if err := bus.Dispatch(&cmd); err != nil { + srv.log.Error("Problem expiring user invites", "error", err.Error()) + } else { + srv.log.Debug("Expired user invites", "rows affected", cmd.NumExpired) + } +} diff --git a/pkg/services/sqlstore/migrations/temp_user.go b/pkg/services/sqlstore/migrations/temp_user.go index 3913b18b3d8..59dd15d2505 100644 --- a/pkg/services/sqlstore/migrations/temp_user.go +++ b/pkg/services/sqlstore/migrations/temp_user.go @@ -1,6 +1,11 @@ package migrations -import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +import ( + "time" + + . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "xorm.io/xorm" +) func addTempUserMigrations(mg *Migrator) { tempUserV1 := Table{ @@ -44,4 +49,65 @@ func addTempUserMigrations(mg *Migrator) { {Name: "status", Type: DB_Varchar, Length: 20}, {Name: "remote_addr", Type: DB_Varchar, Length: 255, Nullable: true}, })) + + tempUserV2 := Table{ + Name: "temp_user", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "org_id", Type: DB_BigInt, Nullable: false}, + {Name: "version", Type: DB_Int, Nullable: false}, + {Name: "email", Type: DB_NVarchar, Length: 190}, + {Name: "name", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "role", Type: DB_NVarchar, Length: 20, Nullable: true}, + {Name: "code", Type: DB_NVarchar, Length: 190}, + {Name: "status", Type: DB_Varchar, Length: 20}, + {Name: "invited_by_user_id", Type: DB_BigInt, Nullable: true}, + {Name: "email_sent", Type: DB_Bool}, + {Name: "email_sent_on", Type: DB_DateTime, Nullable: true}, + {Name: "remote_addr", Type: DB_Varchar, Length: 255, Nullable: true}, + {Name: "created", Type: DB_Int, Default: "0", Nullable: false}, + {Name: "updated", Type: DB_Int, Default: "0", Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"email"}, Type: IndexType}, + {Cols: []string{"org_id"}, Type: IndexType}, + {Cols: []string{"code"}, Type: IndexType}, + {Cols: []string{"status"}, Type: IndexType}, + }, + } + + addTableReplaceMigrations(mg, tempUserV1, tempUserV2, 2, map[string]string{ + "id": "id", + "org_id": "org_id", + "version": "version", + "email": "email", + "name": "name", + "role": "role", + "code": "code", + "status": "status", + "invited_by_user_id": "invited_by_user_id", + "email_sent": "email_sent", + "email_sent_on": "email_sent_on", + "remote_addr": "remote_addr", + }) + + // Ensure outstanding invites are given a valid lifetime post-migration + mg.AddMigration("Set created for temp users that will otherwise prematurely expire", &SetCreatedForOutstandingInvites{}) +} + +type SetCreatedForOutstandingInvites struct { + MigrationBase +} + +func (m *SetCreatedForOutstandingInvites) Sql(dialect Dialect) string { + return "code migration" +} + +func (m *SetCreatedForOutstandingInvites) Exec(sess *xorm.Session, mg *Migrator) error { + created := time.Now().Unix() + if _, err := sess.Exec("UPDATE "+mg.Dialect.Quote("temp_user")+ + " SET created = ?, updated = ? WHERE created = '0' AND status in ('SignUpStarted', 'InvitePending')", created, created); err != nil { + return err + } + return nil } diff --git a/pkg/services/sqlstore/temp_user.go b/pkg/services/sqlstore/temp_user.go index 42cadbf1cfc..8f015a19f28 100644 --- a/pkg/services/sqlstore/temp_user.go +++ b/pkg/services/sqlstore/temp_user.go @@ -13,6 +13,7 @@ func init() { bus.AddHandler("sql", UpdateTempUserStatus) bus.AddHandler("sql", GetTempUserByCode) bus.AddHandler("sql", UpdateTempUserWithEmailSent) + bus.AddHandler("sql", ExpireOldUserInvites) } func UpdateTempUserStatus(cmd *models.UpdateTempUserStatusCommand) error { @@ -36,8 +37,8 @@ func CreateTempUser(cmd *models.CreateTempUserCommand) error { RemoteAddr: cmd.RemoteAddr, InvitedByUserId: cmd.InvitedByUserId, EmailSentOn: time.Now(), - Created: time.Now(), - Updated: time.Now(), + Created: time.Now().Unix(), + Updated: time.Now().Unix(), } if _, err := sess.Insert(user); err != nil { @@ -132,3 +133,15 @@ func GetTempUserByCode(query *models.GetTempUserByCodeQuery) error { query.Result = &tempUser return err } + +func ExpireOldUserInvites(cmd *models.ExpireTempUsersCommand) error { + return inTransaction(func(sess *DBSession) error { + var rawSql = "UPDATE temp_user SET status = ?, updated = ? WHERE created <= ? AND status in (?, ?)" + if result, err := sess.Exec(rawSql, string(models.TmpUserExpired), time.Now().Unix(), cmd.OlderThan.Unix(), string(models.TmpUserSignUpStarted), string(models.TmpUserInvitePending)); err != nil { + return err + } else if cmd.NumExpired, err = result.RowsAffected(); err != nil { + return err + } + return nil + }) +} diff --git a/pkg/services/sqlstore/temp_user_test.go b/pkg/services/sqlstore/temp_user_test.go index 956c45e6e4e..791a74d4e47 100644 --- a/pkg/services/sqlstore/temp_user_test.go +++ b/pkg/services/sqlstore/temp_user_test.go @@ -53,8 +53,8 @@ func TestTempUserCommandsAndQueries(t *testing.T) { }) Convey("Should be able update email sent and email sent on", func() { - cmd3 := models.UpdateTempUserWithEmailSentCommand{Code: cmd.Result.Code} - err := UpdateTempUserWithEmailSent(&cmd3) + cmd2 := models.UpdateTempUserWithEmailSentCommand{Code: cmd.Result.Code} + err := UpdateTempUserWithEmailSent(&cmd2) So(err, ShouldBeNil) query := models.GetTempUsersQuery{OrgId: 2256, Status: models.TmpUserInvitePending} @@ -62,7 +62,21 @@ func TestTempUserCommandsAndQueries(t *testing.T) { So(err, ShouldBeNil) So(query.Result[0].EmailSent, ShouldBeTrue) - So(query.Result[0].EmailSentOn, ShouldHappenOnOrAfter, (query.Result[0].Created)) + So(query.Result[0].EmailSentOn.UTC(), ShouldHappenOnOrAfter, query.Result[0].Created.UTC()) + }) + + Convey("Should be able expire temp user", func() { + cmd2 := models.ExpireTempUsersCommand{OlderThan: timeNow()} + err := ExpireOldUserInvites(&cmd2) + So(err, ShouldBeNil) + So(cmd2.NumExpired, ShouldEqual, 1) + + Convey("Should do nothing when no temp users to expire", func() { + cmd2 = models.ExpireTempUsersCommand{OlderThan: timeNow()} + err := ExpireOldUserInvites(&cmd2) + So(err, ShouldBeNil) + So(cmd2.NumExpired, ShouldEqual, 0) + }) }) }) }) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 488a21db291..c6d1d6f5aa8 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -5,6 +5,7 @@ package setting import ( "bytes" + "errors" "fmt" "net/http" "net/url" @@ -311,6 +312,9 @@ type Cfg struct { DateFormats DateFormats + // User + UserInviteMaxLifetime time.Duration + // Annotations AlertingAnnotationCleanupSetting AnnotationCleanupSettings DashboardAnnotationCleanupSettings AnnotationCleanupSettings @@ -1078,6 +1082,17 @@ func readUserSettings(iniFile *ini.File, cfg *Cfg) error { ViewersCanEdit = users.Key("viewers_can_edit").MustBool(false) cfg.EditorsCanAdmin = users.Key("editors_can_admin").MustBool(false) + userInviteMaxLifetimeVal := valueAsString(users, "user_invite_max_lifetime_duration", "24h") + userInviteMaxLifetimeDuration, err := gtime.ParseInterval(userInviteMaxLifetimeVal) + if err != nil { + return err + } + + cfg.UserInviteMaxLifetime = userInviteMaxLifetimeDuration + if cfg.UserInviteMaxLifetime < time.Minute*15 { + return errors.New("the minimum supported value for the `user_invite_max_lifetime_duration` configuration is 15m (15 minutes)") + } + return nil }