diff --git a/emails/templates/verify_email_update.mjml b/emails/templates/verify_email_update.mjml new file mode 100644 index 00000000000..bacbc77922d --- /dev/null +++ b/emails/templates/verify_email_update.mjml @@ -0,0 +1,40 @@ + + + + + + + + + {{ Subject .Subject .TemplateData "Verify your new email - {{.Name}}" }} + + + + + + + + + + +

Hi {{ .Name }},

+
+ + Please click the following link to verify your email within {{ .VerificationEmailLifetimeHours }} hour(s). + + + Verify Email + + + You can also copy and paste this link into your browser directly: + + + {{ .AppUrl }}user/email/update?code={{ .Code }} + +
+
+ + + +
+
diff --git a/emails/templates/verify_email_update.txt b/emails/templates/verify_email_update.txt new file mode 100644 index 00000000000..ae0d29e314e --- /dev/null +++ b/emails/templates/verify_email_update.txt @@ -0,0 +1,6 @@ +[[HiddenSubject .Subject "Verify your new email - [[.Name]]"]] + +Hi [[.Name]], + +Copy and paste the following link directly in your browser to verify your email within [[.VerificationEmailLifetimeHours]] hour(s). +[[.AppUrl]]user/email/update?code=[[.Code]] diff --git a/pkg/api/api.go b/pkg/api/api.go index f35095b548e..df0e8f28f1f 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -184,6 +184,11 @@ func (hs *HTTPServer) registerRoutes() { r.Post("/api/user/signup", quota(user.QuotaTargetSrv), quota(org.QuotaTargetSrv), routing.Wrap(hs.SignUp)) r.Post("/api/user/signup/step2", routing.Wrap(hs.SignUpStep2)) + // update user email + if hs.Cfg.Smtp.Enabled && setting.VerifyEmailEnabled { + r.Get("/user/email/update", reqSignedInNoAnonymous, routing.Wrap(hs.UpdateUserEmail)) + } + // invited r.Get("/api/user/invite/:code", routing.Wrap(hs.GetInviteInfoByCode)) r.Post("/api/user/invite/complete", routing.Wrap(hs.CompleteInvite)) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 264d084089b..963bc703d12 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -233,6 +233,10 @@ func setupScenarioContext(t *testing.T, url string) *scenarioContext { return sc } +func authedUserWithPermissions(userID, orgID int64, permissions []accesscontrol.Permission) *user.SignedInUser { + return &user.SignedInUser{UserID: userID, OrgID: orgID, OrgRole: org.RoleViewer, Permissions: map[int64]map[string][]string{orgID: accesscontrol.GroupScopesByAction(permissions)}} +} + type fakeRenderService struct { rendering.Service } diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 8733d9a9a30..b4493b8cc40 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -179,7 +179,7 @@ type HTTPServer struct { authInfoService login.AuthInfoService authenticator loginpkg.Authenticator teamPermissionsService accesscontrol.TeamPermissionsService - NotificationService *notifications.NotificationService + NotificationService notifications.Service DashboardService dashboards.DashboardService dashboardProvisioningService dashboards.DashboardProvisioningService folderService folder.Service @@ -238,7 +238,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi dataSourcesService datasources.DataSourceService, queryDataService query.Service, pluginFileStore plugins.FileStore, serviceaccountsService serviceaccounts.Service, authInfoService login.AuthInfoService, storageService store.StorageService, httpEntityStore httpentitystore.HTTPEntityStore, - notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService, + notificationService notifications.Service, dashboardService dashboards.DashboardService, dashboardProvisioningService dashboards.DashboardProvisioningService, folderService folder.Service, datasourcePermissionsService permissions.DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService, dashboardsnapshotsService dashboardsnapshots.Service, pluginSettings pluginSettings.Service, diff --git a/pkg/api/user.go b/pkg/api/user.go index 7ce89333105..a5e7c35f23f 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -4,16 +4,22 @@ import ( "context" "errors" "net/http" + "net/mail" + "net/url" "strconv" "strings" + "time" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team" + tempuser "github.com/grafana/grafana/pkg/services/temp_user" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -122,6 +128,7 @@ func (hs *HTTPServer) GetUserByLoginOrEmail(c *contextmodel.ReqContext) response // 200: okResponse // 401: unauthorisedError // 403: forbiddenError +// 409: conflictError // 500: internalServerError func (hs *HTTPServer) UpdateSignedInUser(c *contextmodel.ReqContext) response.Response { cmd := user.UpdateUserCommand{} @@ -156,6 +163,7 @@ func (hs *HTTPServer) UpdateSignedInUser(c *contextmodel.ReqContext) response.Re // 401: unauthorisedError // 403: forbiddenError // 404: notFoundError +// 409: conflictError // 500: internalServerError func (hs *HTTPServer) UpdateUser(c *contextmodel.ReqContext) response.Response { cmd := user.UpdateUserCommand{} @@ -217,6 +225,39 @@ func (hs *HTTPServer) handleUpdateUser(ctx context.Context, cmd user.UpdateUserC } } + // If email is being updated, we need to verify it. Likewise, if username is being updated and the new username + // is an email, we also need to verify it. + // To avoid breaking changes, email verification is implemented in a way that if the email field is being updated, + // all the other fields being updated in the same request are disregarded. We do this because email might need to + // be verified and if so, it goes through a different code flow. + if hs.Cfg.Smtp.Enabled && setting.VerifyEmailEnabled { + query := user.GetUserByIDQuery{ID: cmd.UserID} + usr, err := hs.userService.GetByID(ctx, &query) + if err != nil { + if errors.Is(err, user.ErrUserNotFound) { + return response.Error(http.StatusNotFound, user.ErrUserNotFound.Error(), nil) + } + return response.Error(http.StatusInternalServerError, "Failed to get user", err) + } + + if len(cmd.Email) != 0 && usr.Email != cmd.Email { + // Email is being updated + newEmail, err := ValidateAndNormalizeEmail(cmd.Email) + if err != nil { + return response.Error(http.StatusBadRequest, "Invalid email address", err) + } + + return hs.verifyEmailUpdate(ctx, newEmail, user.EmailUpdateAction, usr) + } + if len(cmd.Login) != 0 && usr.Login != cmd.Login { + // Username is being updated. If it's an email, go through the email verification flow + newEmailLogin, err := ValidateAndNormalizeEmail(cmd.Login) + if err == nil && newEmailLogin != usr.Email { + return hs.verifyEmailUpdate(ctx, newEmailLogin, user.LoginUpdateAction, usr) + } + } + } + if err := hs.userService.Update(ctx, &cmd); err != nil { if errors.Is(err, user.ErrCaseInsensitive) { return response.Error(http.StatusConflict, "Update would result in user login conflict", err) @@ -227,6 +268,104 @@ func (hs *HTTPServer) handleUpdateUser(ctx context.Context, cmd user.UpdateUserC return response.Success("User updated") } +func (hs *HTTPServer) verifyEmailUpdate(ctx context.Context, email string, field user.UpdateEmailActionType, usr *user.User) response.Response { + // Verify that email is not already being used + query := user.GetUserByLoginQuery{LoginOrEmail: email} + existingUsr, err := hs.userService.GetByLogin(ctx, &query) + if err != nil && !errors.Is(err, user.ErrUserNotFound) { + return response.Error(http.StatusInternalServerError, "Failed to validate if email is already in use", err) + } + if existingUsr != nil { + return response.Error(http.StatusConflict, "Email is already being used", nil) + } + + // Invalidate any pending verifications for this user + expireCmd := tempuser.ExpirePreviousVerificationsCommand{InvitedByUserID: usr.ID} + err = hs.tempUserService.ExpirePreviousVerifications(ctx, &expireCmd) + if err != nil { + return response.Error(http.StatusInternalServerError, "Could not invalidate pending email verifications", err) + } + + code, err := util.GetRandomString(20) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to generate random string", err) + } + + tempCmd := tempuser.CreateTempUserCommand{ + OrgID: -1, + Email: email, + Code: code, + Status: tempuser.TmpUserEmailUpdateStarted, + // used to fetch the User in the second step of the verification flow + InvitedByUserID: usr.ID, + // used to determine if the user was updating their email or username in the second step of the verification flow + Name: string(field), + } + + tempUser, err := hs.tempUserService.CreateTempUser(ctx, &tempCmd) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to create email change", err) + } + + emailCmd := notifications.SendVerifyEmailCommand{Email: tempUser.Email, Code: tempUser.Code, User: usr} + err = hs.NotificationService.SendVerificationEmail(ctx, &emailCmd) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to send verification email", err) + } + + // Record email as sent + emailSentCmd := tempuser.UpdateTempUserWithEmailSentCommand{Code: tempUser.Code} + err = hs.tempUserService.UpdateTempUserWithEmailSent(ctx, &emailSentCmd) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to record verification email", err) + } + + return response.Success("Email sent for verification") +} + +// swagger:route GET /user/email/update user updateUserEmail +// +// Update user email. +// +// Update the email of user given a verification code. +// +// Responses: +// 302: okResponse +func (hs *HTTPServer) UpdateUserEmail(c *contextmodel.ReqContext) response.Response { + var err error + + q := c.Req.URL.Query() + code, err := url.QueryUnescape(q.Get("code")) + if err != nil || code == "" { + return hs.RedirectResponseWithError(c, errors.New("bad request data")) + } + + tempUser, err := hs.validateEmailCode(c.Req.Context(), code) + if err != nil { + return hs.RedirectResponseWithError(c, err) + } + + cmd, err := hs.updateCmdFromEmailVerification(c.Req.Context(), tempUser) + if err != nil { + return hs.RedirectResponseWithError(c, err) + } + + if err := hs.userService.Update(c.Req.Context(), cmd); err != nil { + if errors.Is(err, user.ErrCaseInsensitive) { + return hs.RedirectResponseWithError(c, errors.New("update would result in user login conflict")) + } + return hs.RedirectResponseWithError(c, errors.New("failed to update user")) + } + + // Mark temp user as completed + updateTmpUserCmd := tempuser.UpdateTempUserStatusCommand{Code: code, Status: tempuser.TmpUserEmailUpdateCompleted} + if err := hs.tempUserService.UpdateTempUserStatus(c.Req.Context(), &updateTmpUserCmd); err != nil { + return hs.RedirectResponseWithError(c, errors.New("failed to update verification status")) + } + + return response.Redirect(hs.Cfg.AppSubURL + "/profile") +} + func (hs *HTTPServer) isExternalUser(ctx context.Context, userID int64) (bool, error) { getAuthQuery := login.GetAuthInfoQuery{UserId: userID} var err error @@ -529,6 +668,57 @@ func (hs *HTTPServer) ClearHelpFlags(c *contextmodel.ReqContext) response.Respon return response.JSON(http.StatusOK, &util.DynMap{"message": "Help flag set", "helpFlags1": cmd.HelpFlags1}) } +func (hs *HTTPServer) updateCmdFromEmailVerification(ctx context.Context, tempUser *tempuser.TempUserDTO) (*user.UpdateUserCommand, error) { + userQuery := user.GetUserByLoginQuery{LoginOrEmail: tempUser.InvitedByLogin} + usr, err := hs.userService.GetByLogin(ctx, &userQuery) + if err != nil { + if errors.Is(err, user.ErrUserNotFound) { + return nil, user.ErrUserNotFound + } + return nil, errors.New("failed to get user") + } + + cmd := &user.UpdateUserCommand{UserID: usr.ID, Email: tempUser.Email} + + switch tempUser.Name { + case string(user.EmailUpdateAction): + // User updated the email field + if _, err := mail.ParseAddress(usr.Login); err == nil { + // If username was also an email, we update it to keep it in sync with the email field + cmd.Login = tempUser.Email + } + case string(user.LoginUpdateAction): + // User updated the username field with a new email + cmd.Login = tempUser.Email + default: + return nil, errors.New("trying to update email on unknown field") + } + return cmd, nil +} + +func (hs *HTTPServer) validateEmailCode(ctx context.Context, code string) (*tempuser.TempUserDTO, error) { + tempUserQuery := tempuser.GetTempUserByCodeQuery{Code: code} + tempUser, err := hs.tempUserService.GetTempUserByCode(ctx, &tempUserQuery) + if err != nil { + if errors.Is(err, tempuser.ErrTempUserNotFound) { + return nil, errors.New("invalid email verification code") + } + return nil, errors.New("failed to read temp user") + } + + if tempUser.Status != tempuser.TmpUserEmailUpdateStarted { + return nil, errors.New("invalid email verification code") + } + if !tempUser.EmailSent { + return nil, errors.New("verification email was not recorded as sent") + } + if tempUser.EmailSentOn.Add(hs.Cfg.VerificationEmailMaxLifetime).Before(time.Now()) { + return nil, errors.New("invalid email verification code") + } + + return tempUser, nil +} + // swagger:parameters searchUsers type SearchUsersParams struct { // Limit the maximum number of users to return per page diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index e55105145f6..0552c36bf97 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -5,9 +5,18 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" + "strings" "testing" "time" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/notifications" + "github.com/grafana/grafana/pkg/services/secrets/fakes" + tempuser "github.com/grafana/grafana/pkg/services/temp_user" + "github.com/grafana/grafana/pkg/services/temp_user/tempuserimpl" + "github.com/grafana/grafana/pkg/web/webtest" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" @@ -41,6 +50,8 @@ import ( "github.com/grafana/grafana/pkg/setting" ) +const newEmail = "newEmail@localhost" + func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { settings := setting.NewCfg() sqlStore := db.InitTestDB(t) @@ -73,7 +84,6 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) { hs.authInfoService = srv orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotatest.New(false, nil)) require.NoError(t, err) - require.NoError(t, err) userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sc.cfg, nil, nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService()) require.NoError(t, err) hs.userService = userSvc @@ -367,6 +377,627 @@ func TestHTTPServer_UpdateUser(t *testing.T) { }, hs) } +func TestUser_UpdateEmail(t *testing.T) { + doReq := func(req *http.Request, usr *user.User) (*http.Response, error) { + r := webtest.RequestWithSignedInUser( + req, + authedUserWithPermissions( + usr.ID, + usr.OrgID, + []accesscontrol.Permission{ + { + Action: accesscontrol.ActionUsersWrite, + Scope: accesscontrol.ScopeGlobalUsersAll, + }, + }, + ), + ) + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }} + return client.Do(r) + } + + sendUpdateReq := func(server *webtest.Server, usr *user.User, body string) { + req := server.NewRequest( + http.MethodPut, + "/api/user", + strings.NewReader(body), + ) + req.Header.Add("Content-Type", "application/json") + res, err := doReq(req, usr) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + require.NoError(t, res.Body.Close()) + } + + sendVerificationReq := func(server *webtest.Server, usr *user.User, code string) { + url := fmt.Sprintf("/user/email/update?code=%s", url.QueryEscape(code)) + req := server.NewGetRequest(url) + res, err := doReq(req, usr) + require.NoError(t, err) + assert.Equal(t, http.StatusFound, res.StatusCode) + require.NoError(t, res.Body.Close()) + } + + getVerificationTempUser := func(tempUserSvc tempuser.Service, code string) *tempuser.TempUserDTO { + tmpUserQuery := tempuser.GetTempUserByCodeQuery{Code: code} + tmpUser, err := tempUserSvc.GetTempUserByCode(context.Background(), &tmpUserQuery) + require.NoError(t, err) + return tmpUser + } + + verifyEmailData := func(tempUserSvc tempuser.Service, nsMock *notifications.NotificationServiceMock, originalUsr *user.User, newEmail string) { + verification := nsMock.EmailVerification + tmpUsr := getVerificationTempUser(tempUserSvc, verification.Code) + + require.True(t, nsMock.EmailVerified) + require.Equal(t, newEmail, verification.Email) + require.Equal(t, originalUsr.ID, verification.User.ID) + require.Equal(t, tmpUsr.Code, verification.Code) + } + + verifyUserNotUpdated := func(userSvc user.Service, usr *user.User) { + userQuery := user.GetUserByIDQuery{ID: usr.ID} + checkUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.Equal(t, usr.Email, checkUsr.Email) + require.Equal(t, usr.Login, checkUsr.Login) + require.Equal(t, usr.Name, checkUsr.Name) + } + + setupScenario := func(cfg *setting.Cfg) (*webtest.Server, user.Service, tempuser.Service, *notifications.NotificationServiceMock) { + setting.VerifyEmailEnabled = true + settings := setting.NewCfg() + settings.Smtp.Enabled = true + settings.VerificationEmailMaxLifetime = 1 * time.Hour + + if cfg != nil { + settings = cfg + } + + nsMock := notifications.MockNotificationService() + sqlStore := db.InitTestDB(t) + sqlStore.Cfg = settings + + tempUserSvc := tempuserimpl.ProvideService(sqlStore, settings) + orgSvc, err := orgimpl.ProvideService(sqlStore, settings, quotatest.New(false, nil)) + require.NoError(t, err) + userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, settings, nil, nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService()) + require.NoError(t, err) + + server := SetupAPITestServer(t, func(hs *HTTPServer) { + hs.Cfg = settings + + hs.SQLStore = sqlStore + hs.userService = userSvc + hs.tempUserService = tempUserSvc + hs.NotificationService = nsMock + hs.SecretsService = fakes.NewFakeSecretsService() + // User is internal + hs.authInfoService = &logintest.AuthInfoServiceFake{ExpectedError: user.ErrUserNotFound} + }) + + return server, userSvc, tempUserSvc, nsMock + } + + createUser := func(userSvc user.Service, name string, email string, login string) *user.User { + createUserCmd := user.CreateUserCommand{ + Email: email, + Name: name, + Login: login, + Company: "testCompany", + IsAdmin: true, + } + usr, err := userSvc.Create(context.Background(), &createUserCmd) + require.NoError(t, err) + return usr + } + + disabledCases := []struct { + Name string + Field user.UpdateEmailActionType + }{ + { + Name: "Updating Email field", + Field: user.EmailUpdateAction, + }, + { + Name: "Updating Login (username) field", + Field: user.LoginUpdateAction, + }, + } + + for _, tt := range disabledCases { + t.Run(tt.Name, func(t *testing.T) { + t.Run("With verification disabled should update without verifying", func(t *testing.T) { + tests := []struct { + name string + smtpConfigured bool + verifyEmailEnabled bool + }{ + { + name: "SMTP not configured", + smtpConfigured: false, + verifyEmailEnabled: true, + }, + { + name: "config verify_email_enabled = false", + smtpConfigured: true, + verifyEmailEnabled: false, + }, + { + name: "config verify_email_enabled = false and SMTP not configured", + smtpConfigured: false, + verifyEmailEnabled: false, + }, + } + for _, ttt := range tests { + var body string + newName := "newName" + + settings := setting.NewCfg() + settings.Smtp.Enabled = ttt.smtpConfigured + server, userSvc, _, nsMock := setupScenario(settings) + + // Override after calling setupScenario() + setting.VerifyEmailEnabled = ttt.verifyEmailEnabled + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + switch tt.Field { + case user.LoginUpdateAction: + body = fmt.Sprintf(`{"login": "%s", "name": "%s"}`, newEmail, newName) + case user.EmailUpdateAction: + body = fmt.Sprintf(`{"email": "%s", "login": "%s", "name": "%s"}`, newEmail, originalUsr.Login, newName) + } + sendUpdateReq(server, originalUsr, body) + + // Verify that email has not been sent + require.False(t, nsMock.EmailVerified) + + // Verify Email has been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + + switch tt.Field { + case user.LoginUpdateAction: + require.Equal(t, originalUsr.Email, updatedUsr.Email) + require.NotEqual(t, originalUsr.Login, updatedUsr.Login) + require.Equal(t, newEmail, updatedUsr.Login) + case user.EmailUpdateAction: + require.Equal(t, originalUsr.Login, updatedUsr.Login) + require.NotEqual(t, originalUsr.Email, updatedUsr.Email) + require.Equal(t, newEmail, updatedUsr.Email) + } + + // Verify name has been updated + require.NotEqual(t, originalUsr.Name, updatedUsr.Name) + require.Equal(t, newName, updatedUsr.Name) + + // Fields unchanged + require.Equal(t, originalUsr.Company, updatedUsr.Company) + } + }) + }) + } + + t.Run("Update Email and disregard other fields", func(t *testing.T) { + server, userSvc, tempUserSvc, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + newName := "newName" + body := fmt.Sprintf(`{"email": "%s", "name": "%s"}`, newEmail, newName) + sendUpdateReq(server, originalUsr, body) + + // Verify email data + verifyEmailData(tempUserSvc, nsMock, originalUsr, newEmail) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Second part of the verification flow, when user clicks email button + code := nsMock.EmailVerification.Code + sendVerificationReq(server, originalUsr, code) + + // Verify Email has been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Email, updatedUsr.Email) + require.Equal(t, newEmail, updatedUsr.Email) + // Fields unchanged + require.Equal(t, originalUsr.Login, updatedUsr.Login) + require.Equal(t, originalUsr.Name, updatedUsr.Name) + require.NotEqual(t, newName, updatedUsr.Name) + }) + + t.Run("Update Email when Login was also an email should update both", func(t *testing.T) { + server, userSvc, tempUserSvc, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "email@localhost") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + body := fmt.Sprintf(`{"email": "%s"}`, newEmail) + sendUpdateReq(server, originalUsr, body) + + // Verify email data + verifyEmailData(tempUserSvc, nsMock, originalUsr, newEmail) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Second part of the verification flow, when user clicks email button + code := nsMock.EmailVerification.Code + sendVerificationReq(server, originalUsr, code) + + // Verify Email and Login have been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Email, updatedUsr.Email) + require.Equal(t, newEmail, updatedUsr.Email) + require.Equal(t, newEmail, updatedUsr.Login) + // Fields unchanged + require.Equal(t, originalUsr.Name, updatedUsr.Name) + }) + + t.Run("Update Login with an email should update Email too", func(t *testing.T) { + server, userSvc, tempUserSvc, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + body := fmt.Sprintf(`{"login": "%s"}`, newEmail) + sendUpdateReq(server, originalUsr, body) + + // Verify email data + verifyEmailData(tempUserSvc, nsMock, originalUsr, newEmail) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Second part of the verification flow, when user clicks email button + code := nsMock.EmailVerification.Code + sendVerificationReq(server, originalUsr, code) + + // Verify Email and Login have been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Email, updatedUsr.Email) + require.NotEqual(t, originalUsr.Login, updatedUsr.Login) + require.Equal(t, newEmail, updatedUsr.Email) + require.Equal(t, newEmail, updatedUsr.Login) + // Fields unchanged + require.Equal(t, originalUsr.Name, updatedUsr.Name) + }) + + t.Run("Update Login should not need verification if it is not an email", func(t *testing.T) { + server, userSvc, _, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + newLogin := "newLogin" + newName := "newName" + body := fmt.Sprintf(`{"login": "%s", "name": "%s"}`, newLogin, newName) + sendUpdateReq(server, originalUsr, body) + + // Verify that email has not been sent + require.False(t, nsMock.EmailVerified) + + // Verify Login has been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Login, updatedUsr.Login) + require.NotEqual(t, originalUsr.Name, updatedUsr.Name) + require.Equal(t, newLogin, updatedUsr.Login) + require.Equal(t, newName, updatedUsr.Name) + // Fields unchanged + require.Equal(t, originalUsr.Email, updatedUsr.Email) + }) + + t.Run("Update Login should not need verification if it is being updated to the already configured email", func(t *testing.T) { + server, userSvc, _, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + body := fmt.Sprintf(`{"login": "%s"}`, originalUsr.Email) + sendUpdateReq(server, originalUsr, body) + + // Verify that email has not been sent + require.False(t, nsMock.EmailVerified) + + // Verify Login has been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Login, updatedUsr.Login) + require.Equal(t, originalUsr.Email, updatedUsr.Login) + require.Equal(t, originalUsr.Email, updatedUsr.Email) + }) + + t.Run("Update Login and Email with different email values at once should disregard the Login update", func(t *testing.T) { + server, userSvc, tempUserSvc, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + newLogin := "newEmail2@localhost" + body := fmt.Sprintf(`{"email": "%s", "login": "%s"}`, newEmail, newLogin) + sendUpdateReq(server, originalUsr, body) + + // Verify email data + verifyEmailData(tempUserSvc, nsMock, originalUsr, newEmail) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Second part of the verification flow, when user clicks email button + code := nsMock.EmailVerification.Code + sendVerificationReq(server, originalUsr, code) + + // Verify only Email has been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Email, updatedUsr.Email) + require.Equal(t, newEmail, updatedUsr.Email) + // Fields unchanged + require.NotEqual(t, newLogin, updatedUsr.Login) + require.Equal(t, originalUsr.Login, updatedUsr.Login) + require.Equal(t, originalUsr.Name, updatedUsr.Name) + }) + + t.Run("Update Login and Email with different email values at once when Login was already an email should update both with Email", func(t *testing.T) { + server, userSvc, tempUserSvc, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "email@localhost") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + newLogin := "newEmail2@localhost" + body := fmt.Sprintf(`{"email": "%s", "login": "%s"}`, newEmail, newLogin) + sendUpdateReq(server, originalUsr, body) + + // Verify email data + verifyEmailData(tempUserSvc, nsMock, originalUsr, newEmail) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Second part of the verification flow, when user clicks email button + code := nsMock.EmailVerification.Code + sendVerificationReq(server, originalUsr, code) + + // Verify only Email has been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Email, updatedUsr.Email) + require.NotEqual(t, originalUsr.Login, updatedUsr.Login) + require.NotEqual(t, newLogin, updatedUsr.Login) + require.Equal(t, newEmail, updatedUsr.Email) + require.Equal(t, newEmail, updatedUsr.Login) + // Fields unchanged + require.Equal(t, originalUsr.Name, updatedUsr.Name) + }) + + t.Run("Email verification should expire", func(t *testing.T) { + cfg := setting.NewCfg() + cfg.Smtp.Enabled = true + cfg.VerificationEmailMaxLifetime = 0 // Expire instantly + + server, userSvc, tempUserSvc, nsMock := setupScenario(cfg) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + body := fmt.Sprintf(`{"email": "%s"}`, newEmail) + sendUpdateReq(server, originalUsr, body) + + // Verify email data + verifyEmailData(tempUserSvc, nsMock, originalUsr, newEmail) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Second part of the verification flow, when user clicks email button + code := nsMock.EmailVerification.Code + sendVerificationReq(server, originalUsr, code) + + // Verify user has not been updated + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, newEmail, updatedUsr.Email) + require.Equal(t, originalUsr.Email, updatedUsr.Email) + require.Equal(t, originalUsr.Login, updatedUsr.Login) + }) + + t.Run("A new verification should revoke other pending verifications", func(t *testing.T) { + server, userSvc, tempUserSvc, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // First email verification + firstNewEmail := "newEmail1@localhost" + body := fmt.Sprintf(`{"email": "%s"}`, firstNewEmail) + sendUpdateReq(server, originalUsr, body) + verifyEmailData(tempUserSvc, nsMock, originalUsr, firstNewEmail) + firstCode := nsMock.EmailVerification.Code + + // Second email verification + secondNewEmail := "newEmail2@localhost" + body = fmt.Sprintf(`{"email": "%s"}`, secondNewEmail) + sendUpdateReq(server, originalUsr, body) + verifyEmailData(tempUserSvc, nsMock, originalUsr, secondNewEmail) + secondCode := nsMock.EmailVerification.Code + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Try to follow through with the first verification unsuccessfully + sendVerificationReq(server, originalUsr, firstCode) + verifyUserNotUpdated(userSvc, originalUsr) + + // Follow through with second verification successfully + sendVerificationReq(server, originalUsr, secondCode) + + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.NotEqual(t, originalUsr.Email, updatedUsr.Email) + require.Equal(t, secondNewEmail, updatedUsr.Email) + // Fields unchanged + require.Equal(t, originalUsr.Login, updatedUsr.Login) + }) + + t.Run("Email verification should fail if code is not valid", func(t *testing.T) { + server, userSvc, tempUserSvc, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Start email update + body := fmt.Sprintf(`{"email": "%s"}`, newEmail) + sendUpdateReq(server, originalUsr, body) + + // Verify email data + verifyEmailData(tempUserSvc, nsMock, originalUsr, newEmail) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Second part of the verification flow should fail if using the wrong code + sendVerificationReq(server, originalUsr, "notTheRightCode") + verifyUserNotUpdated(userSvc, originalUsr) + }) + + t.Run("Email verification code can only be used once", func(t *testing.T) { + server, userSvc, _, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name", "email@localhost", "login") + + // Start email update + require.NotEqual(t, originalUsr.Email, newEmail) + + body := fmt.Sprintf(`{"email": "%s"}`, newEmail) + sendUpdateReq(server, originalUsr, body) + + // Verify user has not been updated yet + verifyUserNotUpdated(userSvc, originalUsr) + + // Use code to verify successfully + codeToReuse := nsMock.EmailVerification.Code + sendVerificationReq(server, originalUsr, codeToReuse) + + // User should have an updated Email + userQuery := user.GetUserByIDQuery{ID: originalUsr.ID} + updatedUsr, err := userSvc.GetByID(context.Background(), &userQuery) + require.NoError(t, err) + require.Equal(t, newEmail, updatedUsr.Email) + + // Change email back to what it was + body = fmt.Sprintf(`{"email": "%s"}`, originalUsr.Email) + sendUpdateReq(server, originalUsr, body) + sendVerificationReq(server, originalUsr, nsMock.EmailVerification.Code) + verifyUserNotUpdated(userSvc, originalUsr) + + // Re-use code to verify new email again, unsuccessfully + sendVerificationReq(server, originalUsr, codeToReuse) + verifyUserNotUpdated(userSvc, originalUsr) + }) + + t.Run("Update Email with an email that is already being used should fail", func(t *testing.T) { + testCases := []struct { + description string + clashLogin bool + }{ + { + description: "when Email clashes", + clashLogin: false, + }, + { + description: "when Login clashes", + clashLogin: true, + }, + } + for _, tt := range testCases { + t.Run(tt.description, func(t *testing.T) { + server, userSvc, _, nsMock := setupScenario(nil) + + originalUsr := createUser(userSvc, "name1", "email1@localhost", "login1@localhost") + badUsr := createUser(userSvc, "name2", "email2@localhost", "login2") + + // Verify that no email has been sent yet + require.False(t, nsMock.EmailVerified) + + // Update `badUsr` to use the same email as `originalUsr` + body := fmt.Sprintf(`{"email": "%s"}`, originalUsr.Email) + if tt.clashLogin { + body = fmt.Sprintf(`{"login": "%s"}`, originalUsr.Login) + } + req := server.NewRequest( + http.MethodPut, + "/api/user", + strings.NewReader(body), + ) + req.Header.Add("Content-Type", "application/json") + res, err := doReq(req, badUsr) + require.NoError(t, err) + assert.Equal(t, http.StatusConflict, res.StatusCode) + require.NoError(t, res.Body.Close()) + + // Verify that no email has been sent + require.False(t, nsMock.EmailVerified) + + // Verify user has not been updated + verifyUserNotUpdated(userSvc, badUsr) + }) + } + }) +} + type updateUserContext struct { desc string url string diff --git a/pkg/services/cleanup/cleanup.go b/pkg/services/cleanup/cleanup.go index ecfe5fc0e4d..33f5d3bb702 100644 --- a/pkg/services/cleanup/cleanup.go +++ b/pkg/services/cleanup/cleanup.go @@ -102,6 +102,7 @@ func (srv *CleanUpService) clean(ctx context.Context) { {"expire old user invites", srv.expireOldUserInvites}, {"delete stale short URLs", srv.deleteStaleShortURLs}, {"delete stale query history", srv.deleteStaleQueryHistory}, + {"expire old email verifications", srv.expireOldVerifications}, } logger := srv.log.FromContext(ctx) @@ -237,6 +238,21 @@ func (srv *CleanUpService) expireOldUserInvites(ctx context.Context) { } } +func (srv *CleanUpService) expireOldVerifications(ctx context.Context) { + logger := srv.log.FromContext(ctx) + maxVerificationLifetime := srv.Cfg.VerificationEmailMaxLifetime + + cmd := tempuser.ExpireTempUsersCommand{ + OlderThan: time.Now().Add(-maxVerificationLifetime), + } + + if err := srv.tempUserService.ExpireOldVerifications(ctx, &cmd); err != nil { + logger.Error("Problem expiring email verifications", "error", err.Error()) + } else { + logger.Debug("Expired email verifications", "rows affected", cmd.NumExpired) + } +} + func (srv *CleanUpService) deleteStaleShortURLs(ctx context.Context) { logger := srv.log.FromContext(ctx) cmd := shorturls.DeleteShortUrlCommand{ diff --git a/pkg/services/notifications/mock.go b/pkg/services/notifications/mock.go index 2e874ad6570..89240b26bcb 100644 --- a/pkg/services/notifications/mock.go +++ b/pkg/services/notifications/mock.go @@ -2,13 +2,17 @@ package notifications import ( "context" + + "github.com/grafana/grafana/pkg/services/user" ) type NotificationServiceMock struct { - Webhook SendWebhookSync - EmailSync SendEmailCommandSync - Email SendEmailCommand - ShouldError error + Webhook SendWebhookSync + EmailSync SendEmailCommandSync + Email SendEmailCommand + EmailVerified bool + EmailVerification SendVerifyEmailCommand + ShouldError error WebhookHandler func(context.Context, *SendWebhookSync) error EmailHandlerSync func(context.Context, *SendEmailCommandSync) error @@ -39,4 +43,20 @@ func (ns *NotificationServiceMock) SendEmailCommandHandler(ctx context.Context, return ns.ShouldError } +func (ns *NotificationServiceMock) SendResetPasswordEmail(ctx context.Context, cmd *SendResetPasswordEmailCommand) error { + // TODO: Implement if needed + return ns.ShouldError +} + +func (ns *NotificationServiceMock) ValidateResetPasswordCode(ctx context.Context, query *ValidateResetPasswordCodeQuery, userByLogin GetUserByLoginFunc) (*user.User, error) { + // TODO: Implement if needed + return nil, ns.ShouldError +} + +func (ns *NotificationServiceMock) SendVerificationEmail(ctx context.Context, cmd *SendVerifyEmailCommand) error { + ns.EmailVerified = true + ns.EmailVerification = *cmd + return ns.ShouldError +} + func MockNotificationService() *NotificationServiceMock { return &NotificationServiceMock{} } diff --git a/pkg/services/notifications/models.go b/pkg/services/notifications/models.go index 2765bb7f34f..5ab7d5731c3 100644 --- a/pkg/services/notifications/models.go +++ b/pkg/services/notifications/models.go @@ -51,3 +51,9 @@ type SendResetPasswordEmailCommand struct { type ValidateResetPasswordCodeQuery struct { Code string } + +type SendVerifyEmailCommand struct { + User *user.User + Code string + Email string +} diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go index 55a9839b527..72bde100800 100644 --- a/pkg/services/notifications/notifications.go +++ b/pkg/services/notifications/notifications.go @@ -28,15 +28,25 @@ type EmailSender interface { SendEmailCommandHandlerSync(ctx context.Context, cmd *SendEmailCommandSync) error SendEmailCommandHandler(ctx context.Context, cmd *SendEmailCommand) error } +type PasswordResetMailer interface { + SendResetPasswordEmail(ctx context.Context, cmd *SendResetPasswordEmailCommand) error + ValidateResetPasswordCode(ctx context.Context, query *ValidateResetPasswordCodeQuery, userByLogin GetUserByLoginFunc) (*user.User, error) +} +type EmailVerificationMailer interface { + SendVerificationEmail(ctx context.Context, cmd *SendVerifyEmailCommand) error +} type Service interface { WebhookSender EmailSender + PasswordResetMailer + EmailVerificationMailer } var mailTemplates *template.Template var tmplResetPassword = "reset_password" var tmplSignUpStarted = "signup_started" var tmplWelcomeOnSignUp = "welcome_on_signup" +var tmplVerifyEmail = "verify_email_update" func ProvideService(bus bus.Bus, cfg *setting.Cfg, mailer Mailer, store TempUserStore) (*NotificationService, error) { ns := &NotificationService{ @@ -257,6 +267,20 @@ func (ns *NotificationService) ValidateResetPasswordCode(ctx context.Context, qu return user, nil } +func (ns *NotificationService) SendVerificationEmail(ctx context.Context, cmd *SendVerifyEmailCommand) error { + return ns.SendEmailCommandHandlerSync(ctx, &SendEmailCommandSync{ + SendEmailCommand: SendEmailCommand{ + To: []string{cmd.Email}, + Template: tmplVerifyEmail, + Data: map[string]any{ + "Code": url.QueryEscape(cmd.Code), + "Name": cmd.User.Name, + "VerificationEmailLifetimeHours": int(ns.Cfg.VerificationEmailMaxLifetime.Hours()), + }, + }, + }) +} + func (ns *NotificationService) signUpStartedHandler(ctx context.Context, evt *events.SignUpStarted) error { if !setting.VerifyEmailEnabled { return nil diff --git a/pkg/services/temp_user/model.go b/pkg/services/temp_user/model.go index 1e05d15d832..b0949a79381 100644 --- a/pkg/services/temp_user/model.go +++ b/pkg/services/temp_user/model.go @@ -15,11 +15,14 @@ var ( type TempUserStatus string const ( - TmpUserSignUpStarted TempUserStatus = "SignUpStarted" - TmpUserInvitePending TempUserStatus = "InvitePending" - TmpUserCompleted TempUserStatus = "Completed" - TmpUserRevoked TempUserStatus = "Revoked" - TmpUserExpired TempUserStatus = "Expired" + TmpUserSignUpStarted TempUserStatus = "SignUpStarted" + TmpUserInvitePending TempUserStatus = "InvitePending" + TmpUserCompleted TempUserStatus = "Completed" + TmpUserRevoked TempUserStatus = "Revoked" + TmpUserExpired TempUserStatus = "Expired" + TmpUserEmailUpdateStarted TempUserStatus = "EmailUpdateStarted" + TmpUserEmailUpdateCompleted TempUserStatus = "EmailUpdateCompleted" + TmpUserEmailUpdateExpired TempUserStatus = "EmailUpdateExpired" ) // TempUser holds data for org invites and unconfirmed sign ups @@ -67,6 +70,12 @@ type ExpireTempUsersCommand struct { NumExpired int64 } +type ExpirePreviousVerificationsCommand struct { + InvitedByUserID int64 + + NumExpired int64 +} + type UpdateTempUserWithEmailSentCommand struct { Code string } diff --git a/pkg/services/temp_user/temp_user.go b/pkg/services/temp_user/temp_user.go index 91b7e49801d..162e35459cd 100644 --- a/pkg/services/temp_user/temp_user.go +++ b/pkg/services/temp_user/temp_user.go @@ -11,4 +11,6 @@ type Service interface { GetTempUsersQuery(ctx context.Context, query *GetTempUsersQuery) ([]*TempUserDTO, error) GetTempUserByCode(ctx context.Context, query *GetTempUserByCodeQuery) (*TempUserDTO, error) ExpireOldUserInvites(ctx context.Context, cmd *ExpireTempUsersCommand) error + ExpireOldVerifications(ctx context.Context, cmd *ExpireTempUsersCommand) error + ExpirePreviousVerifications(ctx context.Context, cmd *ExpirePreviousVerificationsCommand) error } diff --git a/pkg/services/temp_user/tempuserimpl/store.go b/pkg/services/temp_user/tempuserimpl/store.go index d362b4ba897..a63e3f33f18 100644 --- a/pkg/services/temp_user/tempuserimpl/store.go +++ b/pkg/services/temp_user/tempuserimpl/store.go @@ -16,6 +16,8 @@ type store interface { GetTempUsersQuery(ctx context.Context, query *tempuser.GetTempUsersQuery) ([]*tempuser.TempUserDTO, error) GetTempUserByCode(ctx context.Context, query *tempuser.GetTempUserByCodeQuery) (*tempuser.TempUserDTO, error) ExpireOldUserInvites(ctx context.Context, cmd *tempuser.ExpireTempUsersCommand) error + ExpireOldVerifications(ctx context.Context, cmd *tempuser.ExpireTempUsersCommand) error + ExpirePreviousVerifications(ctx context.Context, cmd *tempuser.ExpirePreviousVerificationsCommand) error } type xormStore struct { @@ -175,3 +177,27 @@ func (ss *xormStore) ExpireOldUserInvites(ctx context.Context, cmd *tempuser.Exp return nil }) } + +func (ss *xormStore) ExpireOldVerifications(ctx context.Context, cmd *tempuser.ExpireTempUsersCommand) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + var rawSQL = "UPDATE temp_user SET status = ?, updated = ? WHERE created <= ? AND status = ?" + if result, err := sess.Exec(rawSQL, string(tempuser.TmpUserEmailUpdateExpired), time.Now().Unix(), cmd.OlderThan.Unix(), string(tempuser.TmpUserEmailUpdateStarted)); err != nil { + return err + } else if cmd.NumExpired, err = result.RowsAffected(); err != nil { + return err + } + return nil + }) +} + +func (ss *xormStore) ExpirePreviousVerifications(ctx context.Context, cmd *tempuser.ExpirePreviousVerificationsCommand) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + var rawSQL = "UPDATE temp_user SET status = ?, updated = ? WHERE invited_by_user_id = ? AND status = ?" + if result, err := sess.Exec(rawSQL, string(tempuser.TmpUserEmailUpdateExpired), time.Now().Unix(), cmd.InvitedByUserID, string(tempuser.TmpUserEmailUpdateStarted)); err != nil { + return err + } else if cmd.NumExpired, err = result.RowsAffected(); err != nil { + return err + } + return nil + }) +} diff --git a/pkg/services/temp_user/tempuserimpl/store_test.go b/pkg/services/temp_user/tempuserimpl/store_test.go index 7fa636110e5..55c70635e99 100644 --- a/pkg/services/temp_user/tempuserimpl/store_test.go +++ b/pkg/services/temp_user/tempuserimpl/store_test.go @@ -112,7 +112,32 @@ func TestIntegrationTempUserCommandsAndQueries(t *testing.T) { require.False(t, queryResult[0].EmailSentOn.UTC().Before(queryResult[0].Created.UTC())) }) - t.Run("Should be able expire temp user", func(t *testing.T) { + t.Run("Should be able expire all pending verifications from a user", func(t *testing.T) { + userID := int64(99) + verifications := 5 + cmd := tempuser.CreateTempUserCommand{ + OrgID: -1, + Name: "email-update", + Code: "asd", + Email: "e@as.co", + Status: tempuser.TmpUserEmailUpdateStarted, + InvitedByUserID: userID, + } + db := db.InitTestDB(t) + store = &xormStore{db: db, cfg: db.Cfg} + + for i := 0; i < verifications; i++ { + tempUser, err = store.CreateTempUser(context.Background(), &cmd) + require.Nil(t, err) + } + + cmd2 := tempuser.ExpirePreviousVerificationsCommand{InvitedByUserID: userID} + err := store.ExpirePreviousVerifications(context.Background(), &cmd2) + require.Nil(t, err) + require.Equal(t, int64(verifications), cmd2.NumExpired) + }) + + t.Run("Should be able expire temp user related to org invite", func(t *testing.T) { setup(t) createdAt := time.Unix(tempUser.Created, 0) cmd2 := tempuser.ExpireTempUsersCommand{OlderThan: createdAt.Add(1 * time.Second)} @@ -128,4 +153,34 @@ func TestIntegrationTempUserCommandsAndQueries(t *testing.T) { require.Equal(t, int64(0), cmd2.NumExpired) }) }) + + t.Run("Should be able expire temp user related to email verification", func(t *testing.T) { + cmd := tempuser.CreateTempUserCommand{ + OrgID: 2256, + Name: "email-update", + Code: "asd", + Email: "e@as.co", + Status: tempuser.TmpUserEmailUpdateStarted, + InvitedByUserID: 99, + } + db := db.InitTestDB(t) + store = &xormStore{db: db, cfg: db.Cfg} + + tempUser, err = store.CreateTempUser(context.Background(), &cmd) + require.Nil(t, err) + + createdAt := time.Unix(tempUser.Created, 0) + cmd2 := tempuser.ExpireTempUsersCommand{OlderThan: createdAt.Add(1 * time.Second)} + err := store.ExpireOldVerifications(context.Background(), &cmd2) + require.Nil(t, err) + require.Equal(t, int64(1), cmd2.NumExpired) + + t.Run("Should do nothing when no temp users to expire", func(t *testing.T) { + createdAt := time.Unix(tempUser.Created, 0) + cmd2 := tempuser.ExpireTempUsersCommand{OlderThan: createdAt.Add(1 * time.Second)} + err := store.ExpireOldVerifications(context.Background(), &cmd2) + require.Nil(t, err) + require.Equal(t, int64(0), cmd2.NumExpired) + }) + }) } diff --git a/pkg/services/temp_user/tempuserimpl/temp_user.go b/pkg/services/temp_user/tempuserimpl/temp_user.go index e6dd24fe452..89795f13640 100644 --- a/pkg/services/temp_user/tempuserimpl/temp_user.go +++ b/pkg/services/temp_user/tempuserimpl/temp_user.go @@ -68,3 +68,19 @@ func (s *Service) ExpireOldUserInvites(ctx context.Context, cmd *tempuser.Expire } return nil } + +func (s *Service) ExpireOldVerifications(ctx context.Context, cmd *tempuser.ExpireTempUsersCommand) error { + err := s.store.ExpireOldVerifications(ctx, cmd) + if err != nil { + return err + } + return nil +} + +func (s *Service) ExpirePreviousVerifications(ctx context.Context, cmd *tempuser.ExpirePreviousVerificationsCommand) error { + err := s.store.ExpirePreviousVerifications(ctx, cmd) + if err != nil { + return err + } + return nil +} diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go index 4de25f1b041..3ad839e78bc 100644 --- a/pkg/services/user/model.go +++ b/pkg/services/user/model.go @@ -19,6 +19,13 @@ const ( HelpFlagDashboardHelp1 ) +type UpdateEmailActionType string + +const ( + EmailUpdateAction UpdateEmailActionType = "email-update" + LoginUpdateAction UpdateEmailActionType = "login-update" +) + // Typed errors var ( ErrCaseInsensitive = errors.New("case insensitive conflict") diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 6ef121f2a36..66fc31c0c9e 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -370,9 +370,10 @@ type Cfg struct { DateFormats DateFormats // User - UserInviteMaxLifetime time.Duration - HiddenUsers map[string]struct{} - CaseInsensitiveLogin bool // Login and Email will be considered case insensitive + UserInviteMaxLifetime time.Duration + HiddenUsers map[string]struct{} + CaseInsensitiveLogin bool // Login and Email will be considered case insensitive + VerificationEmailMaxLifetime time.Duration // Service Accounts SATokenExpirationDayLimit int @@ -1724,6 +1725,13 @@ func readUserSettings(iniFile *ini.File, cfg *Cfg) error { } } + verificationEmailMaxLifetimeVal := valueAsString(users, "verification_email_max_lifetime_duration", "1h") + verificationEmailMaxLifetimeDuration, err := gtime.ParseDuration(verificationEmailMaxLifetimeVal) + if err != nil { + return err + } + cfg.VerificationEmailMaxLifetime = verificationEmailMaxLifetimeDuration + return nil } diff --git a/public/api-merged.json b/public/api-merged.json index dbdfc846a98..4007b970f80 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -9978,6 +9978,9 @@ "403": { "$ref": "#/responses/forbiddenError" }, + "409": { + "$ref": "#/responses/conflictError" + }, "500": { "$ref": "#/responses/internalServerError" } @@ -10008,6 +10011,21 @@ } } }, + "/user/email/update": { + "get": { + "description": "Update the email of user given a verification code.", + "tags": [ + "user" + ], + "summary": "Update user email.", + "operationId": "updateUserEmail", + "responses": { + "302": { + "$ref": "#/responses/okResponse" + } + } + } + }, "/user/helpflags/clear": { "get": { "tags": [ @@ -10653,6 +10671,9 @@ "404": { "$ref": "#/responses/notFoundError" }, + "409": { + "$ref": "#/responses/conflictError" + }, "500": { "$ref": "#/responses/internalServerError" } diff --git a/public/emails/verify_email_update.html b/public/emails/verify_email_update.html new file mode 100644 index 00000000000..be8542832ca --- /dev/null +++ b/public/emails/verify_email_update.html @@ -0,0 +1,215 @@ + + + + + {{ Subject .Subject .TemplateData "Verify your new email - {{.Name}}" }} + {{ __dangerouslyInjectHTML `` }} + + {{ __dangerouslyInjectHTML `` }} + + + + {{ __dangerouslyInjectHTML `` }} + {{ __dangerouslyInjectHTML `` }} + {{ __dangerouslyInjectHTML `` }} + + + {{ __dangerouslyInjectHTML `` }} + + + + + + + +
+ {{ __dangerouslyInjectHTML `` }} +
+ + + + + + +
+ {{ __dangerouslyInjectHTML `` }} +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ {{ __dangerouslyInjectHTML `` }} +
+
+ {{ __dangerouslyInjectHTML `` }} +
+ + + + + + +
+ {{ __dangerouslyInjectHTML `` }} +
+ + + + + + + + + + + + + + + + + + +
+
+

Hi {{ .Name }},

+
+
+
Please click the following link to verify your email within {{ .VerificationEmailLifetimeHours }} hour(s).
+
+ + + + + + +
+ Verify Email +
+
+
You can also copy and paste this link into your browser directly:
+
+ +
+
+ {{ __dangerouslyInjectHTML `` }} +
+
+ {{ __dangerouslyInjectHTML `` }} +
+ + + + + + +
+ {{ __dangerouslyInjectHTML `` }} +
+ + + + + + +
+
© {{ now | date "2006" }} Grafana Labs. Sent by Grafana v{{ .BuildVersion }}.
+
+
+ {{ __dangerouslyInjectHTML `` }} +
+
+ {{ __dangerouslyInjectHTML `` }} +
+ + + diff --git a/public/emails/verify_email_update.txt b/public/emails/verify_email_update.txt new file mode 100644 index 00000000000..cfdd2665bec --- /dev/null +++ b/public/emails/verify_email_update.txt @@ -0,0 +1,9 @@ +{{HiddenSubject .Subject "Verify your new email - {{.Name}}"}} + +Hi {{.Name}}, + +Copy and paste the following link directly in your browser to verify your email within {{.VerificationEmailLifetimeHours}} hour(s). +{{.AppUrl}}user/email/update?code={{.Code}} + + +Sent by Grafana v{{.BuildVersion}} (c) {{now | date "2006"}} Grafana Labs diff --git a/public/openapi3.json b/public/openapi3.json index da61c26763b..41ac75a4c45 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -21913,6 +21913,9 @@ "403": { "$ref": "#/components/responses/forbiddenError" }, + "409": { + "$ref": "#/components/responses/conflictError" + }, "500": { "$ref": "#/components/responses/internalServerError" } @@ -21947,6 +21950,21 @@ ] } }, + "/user/email/update": { + "get": { + "description": "Update the email of user given a verification code.", + "operationId": "updateUserEmail", + "responses": { + "302": { + "$ref": "#/components/responses/okResponse" + } + }, + "summary": "Update user email.", + "tags": [ + "user" + ] + } + }, "/user/helpflags/clear": { "get": { "operationId": "clearHelpFlags", @@ -22617,6 +22635,9 @@ "404": { "$ref": "#/components/responses/notFoundError" }, + "409": { + "$ref": "#/components/responses/conflictError" + }, "500": { "$ref": "#/components/responses/internalServerError" }