Skip to content

Commit 8d9521f

Browse files
Refactor: Email verification (#84393)
* Update template names * Add verifier that we can use to start verify process * Use userVerifier when verifying email on update * Add tests --------- Co-authored-by: Ieva <[email protected]>
1 parent 38a8bf1 commit 8d9521f

File tree

13 files changed

+267
-64
lines changed

13 files changed

+267
-64
lines changed

emails/templates/verify_email_update.mjml emails/templates/verify_email.mjml

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<mj-head>
77
<!-- ⬇ Don't forget to specify an email subject below! ⬇ -->
88
<mj-title>
9-
{{ Subject .Subject .TemplateData "Verify your new email - {{.Name}}" }}
9+
{{ Subject .Subject .TemplateData "Verify your email - {{.Name}}" }}
1010
</mj-title>
1111
<mj-include path="./partials/layout/head.mjml" />
1212
</mj-head>

emails/templates/verify_email_update.txt emails/templates/verify_email.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
[[HiddenSubject .Subject "Verify your new email - [[.Name]]"]]
1+
[[HiddenSubject .Subject "Verify your email - [[.Name]]"]]
22

33
Hi [[.Name]],
44

pkg/api/http_server.go

+3
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ type HTTPServer struct {
217217
clientConfigProvider grafanaapiserver.DirectRestConfigProvider
218218
namespacer request.NamespaceMapper
219219
anonService anonymous.Service
220+
userVerifier user.Verifier
220221
}
221222

222223
type ServerOptions struct {
@@ -259,6 +260,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
259260
annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, oauthTokenService oauthtoken.OAuthTokenService,
260261
statsService stats.Service, authnService authn.Service, pluginsCDNService *pluginscdn.Service, promGatherer prometheus.Gatherer,
261262
starApi *starApi.API, promRegister prometheus.Registerer, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, anonService anonymous.Service,
263+
userVerifier user.Verifier,
262264
) (*HTTPServer, error) {
263265
web.Env = cfg.Env
264266
m := web.New()
@@ -361,6 +363,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
361363
clientConfigProvider: clientConfigProvider,
362364
namespacer: request.GetNamespaceMapper(cfg),
363365
anonService: anonService,
366+
userVerifier: userVerifier,
364367
}
365368
if hs.Listener != nil {
366369
hs.log.Debug("Using provided listener")

pkg/api/user.go

+11-58
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
"github.com/grafana/grafana/pkg/services/auth/identity"
1616
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
1717
"github.com/grafana/grafana/pkg/services/login"
18-
"github.com/grafana/grafana/pkg/services/notifications"
1918
"github.com/grafana/grafana/pkg/services/org"
2019
"github.com/grafana/grafana/pkg/services/team"
2120
tempuser "github.com/grafana/grafana/pkg/services/temp_user"
@@ -245,25 +244,22 @@ func (hs *HTTPServer) handleUpdateUser(ctx context.Context, cmd user.UpdateUserC
245244
usr, err := hs.userService.GetByID(ctx, &query)
246245
if err != nil {
247246
if errors.Is(err, user.ErrUserNotFound) {
248-
return response.Error(http.StatusNotFound, user.ErrUserNotFound.Error(), nil)
247+
return response.Error(http.StatusNotFound, user.ErrUserNotFound.Error(), err)
249248
}
250249
return response.Error(http.StatusInternalServerError, "Failed to get user", err)
251250
}
252251

253252
if len(cmd.Email) != 0 && usr.Email != cmd.Email {
254-
// Email is being updated
255-
newEmail, err := ValidateAndNormalizeEmail(cmd.Email)
253+
normalized, err := ValidateAndNormalizeEmail(cmd.Email)
256254
if err != nil {
257255
return response.Error(http.StatusBadRequest, "Invalid email address", err)
258256
}
259-
260-
return hs.verifyEmailUpdate(ctx, newEmail, user.EmailUpdateAction, usr)
257+
return hs.verifyEmailUpdate(ctx, normalized, user.EmailUpdateAction, usr)
261258
}
262259
if len(cmd.Login) != 0 && usr.Login != cmd.Login {
263-
// Username is being updated. If it's an email, go through the email verification flow
264-
newEmailLogin, err := ValidateAndNormalizeEmail(cmd.Login)
265-
if err == nil && newEmailLogin != usr.Email {
266-
return hs.verifyEmailUpdate(ctx, newEmailLogin, user.LoginUpdateAction, usr)
260+
normalized, err := ValidateAndNormalizeEmail(cmd.Login)
261+
if err == nil && usr.Email != normalized {
262+
return hs.verifyEmailUpdate(ctx, cmd.Login, user.LoginUpdateAction, usr)
267263
}
268264
}
269265
}
@@ -279,55 +275,12 @@ func (hs *HTTPServer) handleUpdateUser(ctx context.Context, cmd user.UpdateUserC
279275
}
280276

281277
func (hs *HTTPServer) verifyEmailUpdate(ctx context.Context, email string, field user.UpdateEmailActionType, usr *user.User) response.Response {
282-
// Verify that email is not already being used
283-
query := user.GetUserByLoginQuery{LoginOrEmail: email}
284-
existingUsr, err := hs.userService.GetByLogin(ctx, &query)
285-
if err != nil && !errors.Is(err, user.ErrUserNotFound) {
286-
return response.Error(http.StatusInternalServerError, "Failed to validate if email is already in use", err)
287-
}
288-
if existingUsr != nil {
289-
return response.Error(http.StatusConflict, "Email is already being used", nil)
290-
}
291-
292-
// Invalidate any pending verifications for this user
293-
expireCmd := tempuser.ExpirePreviousVerificationsCommand{InvitedByUserID: usr.ID}
294-
err = hs.tempUserService.ExpirePreviousVerifications(ctx, &expireCmd)
295-
if err != nil {
296-
return response.Error(http.StatusInternalServerError, "Could not invalidate pending email verifications", err)
297-
}
298-
299-
code, err := util.GetRandomString(20)
300-
if err != nil {
301-
return response.Error(http.StatusInternalServerError, "Failed to generate random string", err)
302-
}
303-
304-
tempCmd := tempuser.CreateTempUserCommand{
305-
OrgID: -1,
278+
if err := hs.userVerifier.VerifyEmail(ctx, user.VerifyEmailCommand{
279+
User: *usr,
306280
Email: email,
307-
Code: code,
308-
Status: tempuser.TmpUserEmailUpdateStarted,
309-
// used to fetch the User in the second step of the verification flow
310-
InvitedByUserID: usr.ID,
311-
// used to determine if the user was updating their email or username in the second step of the verification flow
312-
Name: string(field),
313-
}
314-
315-
tempUser, err := hs.tempUserService.CreateTempUser(ctx, &tempCmd)
316-
if err != nil {
317-
return response.Error(http.StatusInternalServerError, "Failed to create email change", err)
318-
}
319-
320-
emailCmd := notifications.SendVerifyEmailCommand{Email: tempUser.Email, Code: tempUser.Code, User: usr}
321-
err = hs.NotificationService.SendVerificationEmail(ctx, &emailCmd)
322-
if err != nil {
323-
return response.Error(http.StatusInternalServerError, "Failed to send verification email", err)
324-
}
325-
326-
// Record email as sent
327-
emailSentCmd := tempuser.UpdateTempUserWithEmailSentCommand{Code: tempUser.Code}
328-
err = hs.tempUserService.UpdateTempUserWithEmailSent(ctx, &emailSentCmd)
329-
if err != nil {
330-
return response.Error(http.StatusInternalServerError, "Failed to record verification email", err)
281+
Action: field,
282+
}); err != nil {
283+
return response.ErrOrFallback(http.StatusInternalServerError, "Failed to generate email verification", err)
331284
}
332285

333286
return response.Success("Email sent for verification")

pkg/api/user_test.go

+3
Original file line numberDiff line numberDiff line change
@@ -397,13 +397,15 @@ func setupUpdateEmailTests(t *testing.T, cfg *setting.Cfg) (*user.User, *HTTPSer
397397
require.NoError(t, err)
398398

399399
nsMock := notifications.MockNotificationService()
400+
verifier := userimpl.ProvideVerifier(userSvc, tempUserService, nsMock)
400401

401402
hs := &HTTPServer{
402403
Cfg: cfg,
403404
SQLStore: sqlStore,
404405
userService: userSvc,
405406
tempUserService: tempUserService,
406407
NotificationService: nsMock,
408+
userVerifier: verifier,
407409
}
408410
return usr, hs, nsMock
409411
}
@@ -618,6 +620,7 @@ func TestUser_UpdateEmail(t *testing.T) {
618620
hs.tempUserService = tempUserSvc
619621
hs.NotificationService = nsMock
620622
hs.SecretsService = fakes.NewFakeSecretsService()
623+
hs.userVerifier = userimpl.ProvideVerifier(userSvc, tempUserSvc, nsMock)
621624
// User is internal
622625
hs.authInfoService = &authinfotest.FakeService{ExpectedError: user.ErrUserNotFound}
623626
})

pkg/server/wire.go

+3
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ import (
151151
tempuser "github.com/grafana/grafana/pkg/services/temp_user"
152152
"github.com/grafana/grafana/pkg/services/temp_user/tempuserimpl"
153153
"github.com/grafana/grafana/pkg/services/updatechecker"
154+
"github.com/grafana/grafana/pkg/services/user"
154155
"github.com/grafana/grafana/pkg/services/user/userimpl"
155156
"github.com/grafana/grafana/pkg/setting"
156157
"github.com/grafana/grafana/pkg/tsdb/azuremonitor"
@@ -383,6 +384,8 @@ var wireBasicSet = wire.NewSet(
383384
idimpl.ProvideService,
384385
wire.Bind(new(auth.IDService), new(*idimpl.Service)),
385386
cloudmigrationimpl.ProvideService,
387+
userimpl.ProvideVerifier,
388+
wire.Bind(new(user.Verifier), new(*userimpl.Verifier)),
386389
// Kubernetes API server
387390
grafanaapiserver.WireSet,
388391
apiregistry.WireSet,

pkg/services/notifications/notifications.go

+7-4
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,13 @@ type Service interface {
4343
}
4444

4545
var mailTemplates *template.Template
46-
var tmplResetPassword = "reset_password"
47-
var tmplSignUpStarted = "signup_started"
48-
var tmplWelcomeOnSignUp = "welcome_on_signup"
49-
var tmplVerifyEmail = "verify_email_update"
46+
47+
const (
48+
tmplResetPassword = "reset_password"
49+
tmplSignUpStarted = "signup_started"
50+
tmplWelcomeOnSignUp = "welcome_on_signup"
51+
tmplVerifyEmail = "verify_email"
52+
)
5053

5154
func ProvideService(bus bus.Bus, cfg *setting.Cfg, mailer Mailer, store TempUserStore) (*NotificationService, error) {
5255
ns := &NotificationService{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package tempusertest
2+
3+
import (
4+
"context"
5+
6+
tempuser "github.com/grafana/grafana/pkg/services/temp_user"
7+
)
8+
9+
var _ tempuser.Service = (*FakeTempUserService)(nil)
10+
11+
type FakeTempUserService struct {
12+
tempuser.Service
13+
CreateTempUserFN func(ctx context.Context, cmd *tempuser.CreateTempUserCommand) (*tempuser.TempUser, error)
14+
ExpirePreviousVerificationsFN func(ctx context.Context, cmd *tempuser.ExpirePreviousVerificationsCommand) error
15+
UpdateTempUserWithEmailSentFN func(ctx context.Context, cmd *tempuser.UpdateTempUserWithEmailSentCommand) error
16+
}
17+
18+
func (f *FakeTempUserService) CreateTempUser(ctx context.Context, cmd *tempuser.CreateTempUserCommand) (*tempuser.TempUser, error) {
19+
if f.CreateTempUserFN != nil {
20+
return f.CreateTempUserFN(ctx, cmd)
21+
}
22+
return nil, nil
23+
}
24+
25+
func (f *FakeTempUserService) ExpirePreviousVerifications(ctx context.Context, cmd *tempuser.ExpirePreviousVerificationsCommand) error {
26+
if f.ExpirePreviousVerificationsFN != nil {
27+
return f.ExpirePreviousVerificationsFN(ctx, cmd)
28+
}
29+
return nil
30+
}
31+
32+
func (f *FakeTempUserService) UpdateTempUserWithEmailSent(ctx context.Context, cmd *tempuser.UpdateTempUserWithEmailSentCommand) error {
33+
if f.UpdateTempUserWithEmailSentFN != nil {
34+
return f.UpdateTempUserWithEmailSentFN(ctx, cmd)
35+
}
36+
return nil
37+
}

pkg/services/user/error.go

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ var (
1818
)
1919

2020
var (
21+
ErrEmailConflict = errutil.Conflict("user.email-conflict", errutil.WithPublicMessage("Email is already being used"))
2122
ErrEmptyUsernameAndEmail = errutil.BadRequest(
2223
"user.empty-username-and-email", errutil.WithPublicMessage("Need to specify either username or email"),
2324
)

pkg/services/user/model.go

+6
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,12 @@ type GetUserByIDQuery struct {
220220
ID int64
221221
}
222222

223+
type VerifyEmailCommand struct {
224+
User User
225+
Email string
226+
Action UpdateEmailActionType
227+
}
228+
223229
type ErrCaseInsensitiveLoginConflict struct {
224230
Users []User
225231
}

pkg/services/user/user.go

+4
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,7 @@ type Service interface {
2929
SetUserHelpFlag(context.Context, *SetUserHelpFlagCommand) error
3030
GetProfile(context.Context, *GetUserProfileQuery) (*UserProfileDTO, error)
3131
}
32+
33+
type Verifier interface {
34+
VerifyEmail(ctx context.Context, cmd VerifyEmailCommand) error
35+
}
+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package userimpl
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
8+
"github.com/grafana/grafana/pkg/services/notifications"
9+
tempuser "github.com/grafana/grafana/pkg/services/temp_user"
10+
"github.com/grafana/grafana/pkg/services/user"
11+
"github.com/grafana/grafana/pkg/util"
12+
)
13+
14+
var _ user.Verifier = (*Verifier)(nil)
15+
16+
func ProvideVerifier(us user.Service, ts tempuser.Service, ns notifications.Service) *Verifier {
17+
return &Verifier{us, ts, ns}
18+
}
19+
20+
type Verifier struct {
21+
us user.Service
22+
ts tempuser.Service
23+
ns notifications.Service
24+
}
25+
26+
func (s *Verifier) VerifyEmail(ctx context.Context, cmd user.VerifyEmailCommand) error {
27+
usr, err := s.us.GetByLogin(ctx, &user.GetUserByLoginQuery{
28+
LoginOrEmail: cmd.Email,
29+
})
30+
31+
if err != nil && !errors.Is(err, user.ErrUserNotFound) {
32+
return err
33+
}
34+
35+
// if email is already used by another user we stop here
36+
if usr != nil && usr.ID != cmd.User.ID {
37+
return user.ErrEmailConflict.Errorf("email already used")
38+
}
39+
40+
code, err := util.GetRandomString(20)
41+
if err != nil {
42+
return fmt.Errorf("failed to generate verification code: %w", err)
43+
}
44+
45+
// invalidate any pending verifications for user
46+
if err = s.ts.ExpirePreviousVerifications(
47+
ctx, &tempuser.ExpirePreviousVerificationsCommand{InvitedByUserID: cmd.User.ID},
48+
); err != nil {
49+
return fmt.Errorf("failed to expire previous verifications: %w", err)
50+
}
51+
52+
tmpUsr, err := s.ts.CreateTempUser(ctx, &tempuser.CreateTempUserCommand{
53+
OrgID: -1,
54+
// used to determine if the user was updating their email or username in the second step of the verification flow
55+
Name: string(cmd.Action),
56+
// used to fetch the User in the second step of the verification flow
57+
InvitedByUserID: cmd.User.ID,
58+
Email: cmd.Email,
59+
Code: code,
60+
Status: tempuser.TmpUserEmailUpdateStarted,
61+
})
62+
63+
if err != nil {
64+
return fmt.Errorf("failed to generate temp user for email verification: %w", err)
65+
}
66+
67+
if err := s.ns.SendVerificationEmail(ctx, &notifications.SendVerifyEmailCommand{
68+
User: &cmd.User,
69+
Code: tmpUsr.Code,
70+
Email: cmd.Email,
71+
}); err != nil {
72+
return fmt.Errorf("failed to send verification email: %w", err)
73+
}
74+
75+
if err := s.ts.UpdateTempUserWithEmailSent(ctx, &tempuser.UpdateTempUserWithEmailSentCommand{
76+
Code: tmpUsr.Code,
77+
}); err != nil {
78+
return fmt.Errorf("failed to mark email as sent: %w", err)
79+
}
80+
81+
return nil
82+
}

0 commit comments

Comments
 (0)