mirror of https://github.com/grafana/grafana.git
Auth: Fix email verification bypass when using basic authentication (#82914)
This commit is contained in:
parent
fabaff9a24
commit
46c26bbd0b
|
@ -4,7 +4,7 @@
|
||||||
<!-- css styling -->
|
<!-- css styling -->
|
||||||
<mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" />
|
<mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" />
|
||||||
<mj-head>
|
<mj-head>
|
||||||
<!-- ⬇ Don't forget to specifify an email subject! Use the HTML comment below ⬇ -->
|
<!-- ⬇ Don't forget to specify an email subject! Use the HTML comment below ⬇ -->
|
||||||
<mj-title>
|
<mj-title>
|
||||||
{{ Subject .Subject .TemplateData "{{ .InvitedBy }} has added you to the {{ .OrgName }} organization" }}
|
{{ Subject .Subject .TemplateData "{{ .InvitedBy }} has added you to the {{ .OrgName }} organization" }}
|
||||||
</mj-title>
|
</mj-title>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<!-- css styling -->
|
<!-- css styling -->
|
||||||
<mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" />
|
<mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" />
|
||||||
<mj-head>
|
<mj-head>
|
||||||
<!-- ⬇ Don't forget to specifify an email subject below! ⬇ -->
|
<!-- ⬇ Don't forget to specify an email subject below! ⬇ -->
|
||||||
<mj-title>
|
<mj-title>
|
||||||
{{ Subject .Subject .TemplateData "{{ .InvitedBy }} has invited you to join Grafana" }}
|
{{ Subject .Subject .TemplateData "{{ .InvitedBy }} has invited you to join Grafana" }}
|
||||||
</mj-title>
|
</mj-title>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<!-- css styling -->
|
<!-- css styling -->
|
||||||
<mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" />
|
<mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" />
|
||||||
<mj-head>
|
<mj-head>
|
||||||
<!-- ⬇ Don't forget to specifify an email subject below! ⬇ -->
|
<!-- ⬇ Don't forget to specify an email subject below! ⬇ -->
|
||||||
<mj-title>
|
<mj-title>
|
||||||
{{ Subject .Subject .TemplateData "{{ .Title }}" }}
|
{{ Subject .Subject .TemplateData "{{ .Title }}" }}
|
||||||
</mj-title>
|
</mj-title>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<!-- css styling -->
|
<!-- css styling -->
|
||||||
<mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" />
|
<mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" />
|
||||||
<mj-head>
|
<mj-head>
|
||||||
<!-- ⬇ Don't forget to specifify an email subject below! ⬇ -->
|
<!-- ⬇ Don't forget to specify an email subject below! ⬇ -->
|
||||||
<mj-title>
|
<mj-title>
|
||||||
{{ Subject .Subject .TemplateData "Reset your Grafana password - {{.Name}}" }}
|
{{ Subject .Subject .TemplateData "Reset your Grafana password - {{.Name}}" }}
|
||||||
</mj-title>
|
</mj-title>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<!-- css styling -->
|
<!-- css styling -->
|
||||||
<mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" />
|
<mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" />
|
||||||
<mj-head>
|
<mj-head>
|
||||||
<!-- ⬇ Don't forget to specifify an email subject below! ⬇ -->
|
<!-- ⬇ Don't forget to specify an email subject below! ⬇ -->
|
||||||
<mj-title>
|
<mj-title>
|
||||||
{{ Subject .Subject .TemplateData "Welcome to Grafana, please complete your sign up!" }}
|
{{ Subject .Subject .TemplateData "Welcome to Grafana, please complete your sign up!" }}
|
||||||
</mj-title>
|
</mj-title>
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
<mjml>
|
||||||
|
<!-- global variables -->
|
||||||
|
<mj-include path="./partials/_globals.mjml" />
|
||||||
|
<!-- css styling -->
|
||||||
|
<mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" />
|
||||||
|
<mj-head>
|
||||||
|
<!-- ⬇ Don't forget to specify an email subject below! ⬇ -->
|
||||||
|
<mj-title>
|
||||||
|
{{ Subject .Subject .TemplateData "Verify your new email - {{.Name}}" }}
|
||||||
|
</mj-title>
|
||||||
|
<mj-include path="./partials/layout/head.mjml" />
|
||||||
|
</mj-head>
|
||||||
|
<mj-body>
|
||||||
|
<mj-section>
|
||||||
|
<mj-include path="./partials/layout/header.mjml" />
|
||||||
|
</mj-section>
|
||||||
|
<mj-section css-class="background">
|
||||||
|
<mj-column>
|
||||||
|
<mj-text>
|
||||||
|
<h2>Hi {{ .Name }},</h2>
|
||||||
|
</mj-text>
|
||||||
|
<mj-text>
|
||||||
|
Please click the following link to verify your email within <strong>{{ .VerificationEmailLifetimeHours }} hour(s)</strong>.
|
||||||
|
</mj-text>
|
||||||
|
<mj-button href="{{ .AppUrl }}user/email/update?code={{ .Code }}">
|
||||||
|
Verify Email
|
||||||
|
</mj-button>
|
||||||
|
<mj-text>
|
||||||
|
You can also copy and paste this link into your browser directly:
|
||||||
|
</mj-text>
|
||||||
|
<mj-text>
|
||||||
|
<a rel="noopener" href="{{ .AppUrl }}user/email/update?code={{ .Code }}">{{ .AppUrl }}user/email/update?code={{ .Code }}</a>
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
<mj-section>
|
||||||
|
<mj-include path="./partials/layout/footer.mjml" />
|
||||||
|
</mj-section>
|
||||||
|
</mj-body>
|
||||||
|
</mjml>
|
|
@ -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]]
|
|
@ -4,7 +4,7 @@
|
||||||
<!-- css styling -->
|
<!-- css styling -->
|
||||||
<mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" />
|
<mj-include path="./partials/layout/theme.css" type="css" css-inline="inline" />
|
||||||
<mj-head>
|
<mj-head>
|
||||||
<!-- ⬇ Don't forget to specifify an email subject below! ⬇ -->
|
<!-- ⬇ Don't forget to specify an email subject below! ⬇ -->
|
||||||
<mj-title>
|
<mj-title>
|
||||||
{{ Subject .Subject .TemplateData "Welcome to Grafana" }}
|
{{ Subject .Subject .TemplateData "Welcome to Grafana" }}
|
||||||
</mj-title>
|
</mj-title>
|
||||||
|
|
|
@ -189,6 +189,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", quota(user.QuotaTargetSrv), quota(org.QuotaTargetSrv), routing.Wrap(hs.SignUp))
|
||||||
r.Post("/api/user/signup/step2", routing.Wrap(hs.SignUpStep2))
|
r.Post("/api/user/signup/step2", routing.Wrap(hs.SignUpStep2))
|
||||||
|
|
||||||
|
// update user email
|
||||||
|
if hs.Cfg.Smtp.Enabled && hs.Cfg.VerifyEmailEnabled {
|
||||||
|
r.Get("/user/email/update", reqSignedInNoAnonymous, routing.Wrap(hs.UpdateUserEmail))
|
||||||
|
}
|
||||||
|
|
||||||
// invited
|
// invited
|
||||||
r.Get("/api/user/invite/:code", routing.Wrap(hs.GetInviteInfoByCode))
|
r.Get("/api/user/invite/:code", routing.Wrap(hs.GetInviteInfoByCode))
|
||||||
r.Post("/api/user/invite/complete", routing.Wrap(hs.CompleteInvite))
|
r.Post("/api/user/invite/complete", routing.Wrap(hs.CompleteInvite))
|
||||||
|
|
|
@ -179,7 +179,7 @@ type HTTPServer struct {
|
||||||
queryDataService query.Service
|
queryDataService query.Service
|
||||||
serviceAccountsService serviceaccounts.Service
|
serviceAccountsService serviceaccounts.Service
|
||||||
authInfoService login.AuthInfoService
|
authInfoService login.AuthInfoService
|
||||||
NotificationService *notifications.NotificationService
|
NotificationService notifications.Service
|
||||||
DashboardService dashboards.DashboardService
|
DashboardService dashboards.DashboardService
|
||||||
dashboardProvisioningService dashboards.DashboardProvisioningService
|
dashboardProvisioningService dashboards.DashboardProvisioningService
|
||||||
folderService folder.Service
|
folderService folder.Service
|
||||||
|
@ -242,7 +242,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
||||||
dataSourcesService datasources.DataSourceService, queryDataService query.Service, pluginFileStore plugins.FileStore,
|
dataSourcesService datasources.DataSourceService, queryDataService query.Service, pluginFileStore plugins.FileStore,
|
||||||
serviceaccountsService serviceaccounts.Service,
|
serviceaccountsService serviceaccounts.Service,
|
||||||
authInfoService login.AuthInfoService, storageService store.StorageService,
|
authInfoService login.AuthInfoService, storageService store.StorageService,
|
||||||
notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService,
|
notificationService notifications.Service, dashboardService dashboards.DashboardService,
|
||||||
dashboardProvisioningService dashboards.DashboardProvisioningService, folderService folder.Service,
|
dashboardProvisioningService dashboards.DashboardProvisioningService, folderService folder.Service,
|
||||||
dsGuardian guardian.DatasourceGuardianProvider, alertNotificationService *alerting.AlertNotificationService,
|
dsGuardian guardian.DatasourceGuardianProvider, alertNotificationService *alerting.AlertNotificationService,
|
||||||
dashboardsnapshotsService dashboardsnapshots.Service, pluginSettings pluginSettings.Service,
|
dashboardsnapshotsService dashboardsnapshots.Service, pluginSettings pluginSettings.Service,
|
||||||
|
|
189
pkg/api/user.go
189
pkg/api/user.go
|
@ -4,16 +4,21 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/dtos"
|
"github.com/grafana/grafana/pkg/api/dtos"
|
||||||
"github.com/grafana/grafana/pkg/api/response"
|
"github.com/grafana/grafana/pkg/api/response"
|
||||||
"github.com/grafana/grafana/pkg/services/auth/identity"
|
"github.com/grafana/grafana/pkg/services/auth/identity"
|
||||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||||
"github.com/grafana/grafana/pkg/services/login"
|
"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/org"
|
||||||
"github.com/grafana/grafana/pkg/services/team"
|
"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/services/user"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
"github.com/grafana/grafana/pkg/web"
|
"github.com/grafana/grafana/pkg/web"
|
||||||
|
@ -125,6 +130,7 @@ func (hs *HTTPServer) GetUserByLoginOrEmail(c *contextmodel.ReqContext) response
|
||||||
// 200: okResponse
|
// 200: okResponse
|
||||||
// 401: unauthorisedError
|
// 401: unauthorisedError
|
||||||
// 403: forbiddenError
|
// 403: forbiddenError
|
||||||
|
// 409: conflictError
|
||||||
// 500: internalServerError
|
// 500: internalServerError
|
||||||
func (hs *HTTPServer) UpdateSignedInUser(c *contextmodel.ReqContext) response.Response {
|
func (hs *HTTPServer) UpdateSignedInUser(c *contextmodel.ReqContext) response.Response {
|
||||||
cmd := user.UpdateUserCommand{}
|
cmd := user.UpdateUserCommand{}
|
||||||
|
@ -165,6 +171,7 @@ func (hs *HTTPServer) UpdateSignedInUser(c *contextmodel.ReqContext) response.Re
|
||||||
// 401: unauthorisedError
|
// 401: unauthorisedError
|
||||||
// 403: forbiddenError
|
// 403: forbiddenError
|
||||||
// 404: notFoundError
|
// 404: notFoundError
|
||||||
|
// 409: conflictError
|
||||||
// 500: internalServerError
|
// 500: internalServerError
|
||||||
func (hs *HTTPServer) UpdateUser(c *contextmodel.ReqContext) response.Response {
|
func (hs *HTTPServer) UpdateUser(c *contextmodel.ReqContext) response.Response {
|
||||||
cmd := user.UpdateUserCommand{}
|
cmd := user.UpdateUserCommand{}
|
||||||
|
@ -228,6 +235,39 @@ func (hs *HTTPServer) handleUpdateUser(ctx context.Context, cmd user.UpdateUserC
|
||||||
return response.Err(user.ErrEmptyUsernameAndEmail.Errorf("user cannot be created with empty username and email"))
|
return response.Err(user.ErrEmptyUsernameAndEmail.Errorf("user cannot be created with empty username and email"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 && hs.Cfg.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 err := hs.userService.Update(ctx, &cmd); err != nil {
|
||||||
if errors.Is(err, user.ErrCaseInsensitive) {
|
if errors.Is(err, user.ErrCaseInsensitive) {
|
||||||
return response.Error(http.StatusConflict, "Update would result in user login conflict", err)
|
return response.Error(http.StatusConflict, "Update would result in user login conflict", err)
|
||||||
|
@ -238,6 +278,104 @@ func (hs *HTTPServer) handleUpdateUser(ctx context.Context, cmd user.UpdateUserC
|
||||||
return response.Success("User updated")
|
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) {
|
func (hs *HTTPServer) isExternalUser(ctx context.Context, userID int64) (bool, error) {
|
||||||
getAuthQuery := login.GetAuthInfoQuery{UserId: userID}
|
getAuthQuery := login.GetAuthInfoQuery{UserId: userID}
|
||||||
var err error
|
var err error
|
||||||
|
@ -603,6 +741,57 @@ func getUserID(c *contextmodel.ReqContext) (int64, *response.NormalResponse) {
|
||||||
return userID, nil
|
return userID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// swagger:parameters searchUsers
|
||||||
type SearchUsersParams struct {
|
type SearchUsersParams struct {
|
||||||
// Limit the maximum number of users to return per page
|
// Limit the maximum number of users to return per page
|
||||||
|
|
|
@ -5,9 +5,18 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
@ -40,6 +49,8 @@ import (
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const newEmail = "newEmail@localhost"
|
||||||
|
|
||||||
func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
|
func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||||
settings := setting.NewCfg()
|
settings := setting.NewCfg()
|
||||||
sqlStore := db.InitTestDB(t)
|
sqlStore := db.InitTestDB(t)
|
||||||
|
@ -69,7 +80,6 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
|
||||||
hs.authInfoService = srv
|
hs.authInfoService = srv
|
||||||
orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotatest.New(false, nil))
|
orgSvc, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotatest.New(false, nil))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, err)
|
|
||||||
userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sc.cfg, nil, nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService())
|
userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, sc.cfg, nil, nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
hs.userService = userSvc
|
hs.userService = userSvc
|
||||||
|
@ -363,6 +373,682 @@ func TestHTTPServer_UpdateUser(t *testing.T) {
|
||||||
}, hs)
|
}, hs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setupUpdateEmailTests(t *testing.T, cfg *setting.Cfg) (*user.User, *HTTPServer, *notifications.NotificationServiceMock) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sqlStore := db.InitTestDB(t)
|
||||||
|
sqlStore.Cfg = cfg
|
||||||
|
|
||||||
|
tempUserService := tempuserimpl.ProvideService(sqlStore, cfg)
|
||||||
|
orgSvc, err := orgimpl.ProvideService(sqlStore, cfg, quotatest.New(false, nil))
|
||||||
|
require.NoError(t, err)
|
||||||
|
userSvc, err := userimpl.ProvideService(sqlStore, orgSvc, cfg, nil, nil, quotatest.New(false, nil), supportbundlestest.NewFakeBundleService())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
createUserCmd := user.CreateUserCommand{
|
||||||
|
Email: "testuser@localhost",
|
||||||
|
Name: "testuser",
|
||||||
|
Login: "loginuser",
|
||||||
|
Company: "testCompany",
|
||||||
|
IsAdmin: true,
|
||||||
|
}
|
||||||
|
usr, err := userSvc.Create(context.Background(), &createUserCmd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
nsMock := notifications.MockNotificationService()
|
||||||
|
|
||||||
|
hs := &HTTPServer{
|
||||||
|
Cfg: cfg,
|
||||||
|
SQLStore: sqlStore,
|
||||||
|
userService: userSvc,
|
||||||
|
tempUserService: tempUserService,
|
||||||
|
NotificationService: nsMock,
|
||||||
|
}
|
||||||
|
return usr, hs, nsMock
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUser_UpdateEmail(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
Name string
|
||||||
|
Field user.UpdateEmailActionType
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "Updating Email field",
|
||||||
|
Field: user.EmailUpdateAction,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Updating Login (username) field",
|
||||||
|
Field: user.LoginUpdateAction,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range cases {
|
||||||
|
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 {
|
||||||
|
settings := setting.NewCfg()
|
||||||
|
settings.Smtp.Enabled = ttt.smtpConfigured
|
||||||
|
settings.VerifyEmailEnabled = ttt.verifyEmailEnabled
|
||||||
|
|
||||||
|
usr, hs, nsMock := setupUpdateEmailTests(t, settings)
|
||||||
|
|
||||||
|
updateUserCommand := user.UpdateUserCommand{
|
||||||
|
Email: usr.Email,
|
||||||
|
Name: "newName",
|
||||||
|
Login: usr.Login,
|
||||||
|
UserID: usr.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch tt.Field {
|
||||||
|
case user.LoginUpdateAction:
|
||||||
|
updateUserCommand.Login = newEmail
|
||||||
|
case user.EmailUpdateAction:
|
||||||
|
updateUserCommand.Email = newEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
fn := func(sc *scenarioContext) {
|
||||||
|
// User is internal
|
||||||
|
sc.authInfoService.ExpectedError = user.ErrUserNotFound
|
||||||
|
|
||||||
|
sc.fakeReqWithParams("PUT", sc.url, nil).exec()
|
||||||
|
assert.Equal(t, http.StatusOK, sc.resp.Code)
|
||||||
|
|
||||||
|
// Verify that no email has been sent after update
|
||||||
|
require.False(t, nsMock.EmailVerified)
|
||||||
|
|
||||||
|
userQuery := user.GetUserByIDQuery{ID: usr.ID}
|
||||||
|
updatedUsr, err := hs.userService.GetByID(context.Background(), &userQuery)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify fields have been updated
|
||||||
|
require.NotEqual(t, usr.Name, updatedUsr.Name)
|
||||||
|
require.Equal(t, updateUserCommand.Name, updatedUsr.Name)
|
||||||
|
|
||||||
|
switch tt.Field {
|
||||||
|
case user.LoginUpdateAction:
|
||||||
|
require.Equal(t, usr.Email, updatedUsr.Email)
|
||||||
|
require.NotEqual(t, usr.Login, updatedUsr.Login)
|
||||||
|
require.Equal(t, updateUserCommand.Login, updatedUsr.Login)
|
||||||
|
case user.EmailUpdateAction:
|
||||||
|
require.Equal(t, usr.Login, updatedUsr.Login)
|
||||||
|
require.NotEqual(t, usr.Email, updatedUsr.Email)
|
||||||
|
require.Equal(t, updateUserCommand.Email, updatedUsr.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify other fields have been kept
|
||||||
|
require.Equal(t, usr.Company, updatedUsr.Company)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUserScenario(t, updateUserContext{
|
||||||
|
desc: ttt.name,
|
||||||
|
url: fmt.Sprintf("/api/users/%d", usr.ID),
|
||||||
|
routePattern: "/api/users/:id",
|
||||||
|
cmd: updateUserCommand,
|
||||||
|
fn: fn,
|
||||||
|
}, hs)
|
||||||
|
|
||||||
|
updateSignedInUserScenario(t, updateUserContext{
|
||||||
|
desc: ttt.name,
|
||||||
|
url: "/api/user",
|
||||||
|
routePattern: "/api/user",
|
||||||
|
cmd: updateUserCommand,
|
||||||
|
fn: fn,
|
||||||
|
}, hs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
settings := setting.NewCfg()
|
||||||
|
settings.Smtp.Enabled = true
|
||||||
|
settings.VerificationEmailMaxLifetime = 1 * time.Hour
|
||||||
|
settings.VerifyEmailEnabled = true
|
||||||
|
|
||||||
|
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 = &authinfotest.FakeService{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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
cfg.VerifyEmailEnabled = true
|
||||||
|
|
||||||
|
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 {
|
type updateUserContext struct {
|
||||||
desc string
|
desc string
|
||||||
url string
|
url string
|
||||||
|
|
|
@ -102,6 +102,7 @@ func (srv *CleanUpService) clean(ctx context.Context) {
|
||||||
{"expire old user invites", srv.expireOldUserInvites},
|
{"expire old user invites", srv.expireOldUserInvites},
|
||||||
{"delete stale short URLs", srv.deleteStaleShortURLs},
|
{"delete stale short URLs", srv.deleteStaleShortURLs},
|
||||||
{"delete stale query history", srv.deleteStaleQueryHistory},
|
{"delete stale query history", srv.deleteStaleQueryHistory},
|
||||||
|
{"expire old email verifications", srv.expireOldVerifications},
|
||||||
}
|
}
|
||||||
|
|
||||||
logger := srv.log.FromContext(ctx)
|
logger := srv.log.FromContext(ctx)
|
||||||
|
@ -238,6 +239,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) {
|
func (srv *CleanUpService) deleteStaleShortURLs(ctx context.Context) {
|
||||||
logger := srv.log.FromContext(ctx)
|
logger := srv.log.FromContext(ctx)
|
||||||
cmd := shorturls.DeleteShortUrlCommand{
|
cmd := shorturls.DeleteShortUrlCommand{
|
||||||
|
|
|
@ -2,13 +2,17 @@ package notifications
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NotificationServiceMock struct {
|
type NotificationServiceMock struct {
|
||||||
Webhook SendWebhookSync
|
Webhook SendWebhookSync
|
||||||
EmailSync SendEmailCommandSync
|
EmailSync SendEmailCommandSync
|
||||||
Email SendEmailCommand
|
Email SendEmailCommand
|
||||||
ShouldError error
|
EmailVerified bool
|
||||||
|
EmailVerification SendVerifyEmailCommand
|
||||||
|
ShouldError error
|
||||||
|
|
||||||
WebhookHandler func(context.Context, *SendWebhookSync) error
|
WebhookHandler func(context.Context, *SendWebhookSync) error
|
||||||
EmailHandlerSync func(context.Context, *SendEmailCommandSync) error
|
EmailHandlerSync func(context.Context, *SendEmailCommandSync) error
|
||||||
|
@ -39,4 +43,20 @@ func (ns *NotificationServiceMock) SendEmailCommandHandler(ctx context.Context,
|
||||||
return ns.ShouldError
|
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{} }
|
func MockNotificationService() *NotificationServiceMock { return &NotificationServiceMock{} }
|
||||||
|
|
|
@ -51,3 +51,9 @@ type SendResetPasswordEmailCommand struct {
|
||||||
type ValidateResetPasswordCodeQuery struct {
|
type ValidateResetPasswordCodeQuery struct {
|
||||||
Code string
|
Code string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SendVerifyEmailCommand struct {
|
||||||
|
User *user.User
|
||||||
|
Code string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
|
@ -28,15 +28,25 @@ type EmailSender interface {
|
||||||
SendEmailCommandHandlerSync(ctx context.Context, cmd *SendEmailCommandSync) error
|
SendEmailCommandHandlerSync(ctx context.Context, cmd *SendEmailCommandSync) error
|
||||||
SendEmailCommandHandler(ctx context.Context, cmd *SendEmailCommand) 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 {
|
type Service interface {
|
||||||
WebhookSender
|
WebhookSender
|
||||||
EmailSender
|
EmailSender
|
||||||
|
PasswordResetMailer
|
||||||
|
EmailVerificationMailer
|
||||||
}
|
}
|
||||||
|
|
||||||
var mailTemplates *template.Template
|
var mailTemplates *template.Template
|
||||||
var tmplResetPassword = "reset_password"
|
var tmplResetPassword = "reset_password"
|
||||||
var tmplSignUpStarted = "signup_started"
|
var tmplSignUpStarted = "signup_started"
|
||||||
var tmplWelcomeOnSignUp = "welcome_on_signup"
|
var tmplWelcomeOnSignUp = "welcome_on_signup"
|
||||||
|
var tmplVerifyEmail = "verify_email_update"
|
||||||
|
|
||||||
func ProvideService(bus bus.Bus, cfg *setting.Cfg, mailer Mailer, store TempUserStore) (*NotificationService, error) {
|
func ProvideService(bus bus.Bus, cfg *setting.Cfg, mailer Mailer, store TempUserStore) (*NotificationService, error) {
|
||||||
ns := &NotificationService{
|
ns := &NotificationService{
|
||||||
|
@ -257,6 +267,20 @@ func (ns *NotificationService) ValidateResetPasswordCode(ctx context.Context, qu
|
||||||
return user, nil
|
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 {
|
func (ns *NotificationService) signUpStartedHandler(ctx context.Context, evt *events.SignUpStarted) error {
|
||||||
if !ns.Cfg.VerifyEmailEnabled {
|
if !ns.Cfg.VerifyEmailEnabled {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -15,11 +15,14 @@ var (
|
||||||
type TempUserStatus string
|
type TempUserStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TmpUserSignUpStarted TempUserStatus = "SignUpStarted"
|
TmpUserSignUpStarted TempUserStatus = "SignUpStarted"
|
||||||
TmpUserInvitePending TempUserStatus = "InvitePending"
|
TmpUserInvitePending TempUserStatus = "InvitePending"
|
||||||
TmpUserCompleted TempUserStatus = "Completed"
|
TmpUserCompleted TempUserStatus = "Completed"
|
||||||
TmpUserRevoked TempUserStatus = "Revoked"
|
TmpUserRevoked TempUserStatus = "Revoked"
|
||||||
TmpUserExpired TempUserStatus = "Expired"
|
TmpUserExpired TempUserStatus = "Expired"
|
||||||
|
TmpUserEmailUpdateStarted TempUserStatus = "EmailUpdateStarted"
|
||||||
|
TmpUserEmailUpdateCompleted TempUserStatus = "EmailUpdateCompleted"
|
||||||
|
TmpUserEmailUpdateExpired TempUserStatus = "EmailUpdateExpired"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TempUser holds data for org invites and unconfirmed sign ups
|
// TempUser holds data for org invites and unconfirmed sign ups
|
||||||
|
@ -67,6 +70,12 @@ type ExpireTempUsersCommand struct {
|
||||||
NumExpired int64
|
NumExpired int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExpirePreviousVerificationsCommand struct {
|
||||||
|
InvitedByUserID int64
|
||||||
|
|
||||||
|
NumExpired int64
|
||||||
|
}
|
||||||
|
|
||||||
type UpdateTempUserWithEmailSentCommand struct {
|
type UpdateTempUserWithEmailSentCommand struct {
|
||||||
Code string
|
Code string
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,4 +11,6 @@ type Service interface {
|
||||||
GetTempUsersQuery(ctx context.Context, query *GetTempUsersQuery) ([]*TempUserDTO, error)
|
GetTempUsersQuery(ctx context.Context, query *GetTempUsersQuery) ([]*TempUserDTO, error)
|
||||||
GetTempUserByCode(ctx context.Context, query *GetTempUserByCodeQuery) (*TempUserDTO, error)
|
GetTempUserByCode(ctx context.Context, query *GetTempUserByCodeQuery) (*TempUserDTO, error)
|
||||||
ExpireOldUserInvites(ctx context.Context, cmd *ExpireTempUsersCommand) error
|
ExpireOldUserInvites(ctx context.Context, cmd *ExpireTempUsersCommand) error
|
||||||
|
ExpireOldVerifications(ctx context.Context, cmd *ExpireTempUsersCommand) error
|
||||||
|
ExpirePreviousVerifications(ctx context.Context, cmd *ExpirePreviousVerificationsCommand) error
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,8 @@ type store interface {
|
||||||
GetTempUsersQuery(ctx context.Context, query *tempuser.GetTempUsersQuery) ([]*tempuser.TempUserDTO, error)
|
GetTempUsersQuery(ctx context.Context, query *tempuser.GetTempUsersQuery) ([]*tempuser.TempUserDTO, error)
|
||||||
GetTempUserByCode(ctx context.Context, query *tempuser.GetTempUserByCodeQuery) (*tempuser.TempUserDTO, error)
|
GetTempUserByCode(ctx context.Context, query *tempuser.GetTempUserByCodeQuery) (*tempuser.TempUserDTO, error)
|
||||||
ExpireOldUserInvites(ctx context.Context, cmd *tempuser.ExpireTempUsersCommand) 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 {
|
type xormStore struct {
|
||||||
|
@ -175,3 +177,27 @@ func (ss *xormStore) ExpireOldUserInvites(ctx context.Context, cmd *tempuser.Exp
|
||||||
return nil
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -117,7 +117,32 @@ func TestIntegrationTempUserCommandsAndQueries(t *testing.T) {
|
||||||
require.False(t, queryResult[0].EmailSentOn.UTC().Before(queryResult[0].Created.UTC()))
|
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)
|
setup(t)
|
||||||
createdAt := time.Unix(tempUser.Created, 0)
|
createdAt := time.Unix(tempUser.Created, 0)
|
||||||
cmd2 := tempuser.ExpireTempUsersCommand{OlderThan: createdAt.Add(1 * time.Second)}
|
cmd2 := tempuser.ExpireTempUsersCommand{OlderThan: createdAt.Add(1 * time.Second)}
|
||||||
|
@ -133,4 +158,34 @@ func TestIntegrationTempUserCommandsAndQueries(t *testing.T) {
|
||||||
require.Equal(t, int64(0), cmd2.NumExpired)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,3 +68,19 @@ func (s *Service) ExpireOldUserInvites(ctx context.Context, cmd *tempuser.Expire
|
||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -19,6 +19,13 @@ const (
|
||||||
HelpFlagDashboardHelp1
|
HelpFlagDashboardHelp1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type UpdateEmailActionType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EmailUpdateAction UpdateEmailActionType = "email-update"
|
||||||
|
LoginUpdateAction UpdateEmailActionType = "login-update"
|
||||||
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `xorm:"pk autoincr 'id'"`
|
ID int64 `xorm:"pk autoincr 'id'"`
|
||||||
UID string `json:"uid" xorm:"uid"`
|
UID string `json:"uid" xorm:"uid"`
|
||||||
|
|
|
@ -314,9 +314,10 @@ type Cfg struct {
|
||||||
DateFormats DateFormats
|
DateFormats DateFormats
|
||||||
|
|
||||||
// User
|
// User
|
||||||
UserInviteMaxLifetime time.Duration
|
UserInviteMaxLifetime time.Duration
|
||||||
HiddenUsers map[string]struct{}
|
HiddenUsers map[string]struct{}
|
||||||
CaseInsensitiveLogin bool // Login and Email will be considered case insensitive
|
CaseInsensitiveLogin bool // Login and Email will be considered case insensitive
|
||||||
|
VerificationEmailMaxLifetime time.Duration
|
||||||
|
|
||||||
// Service Accounts
|
// Service Accounts
|
||||||
SATokenExpirationDayLimit int
|
SATokenExpirationDayLimit int
|
||||||
|
@ -1700,6 +1701,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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9602,6 +9602,9 @@
|
||||||
"403": {
|
"403": {
|
||||||
"$ref": "#/responses/forbiddenError"
|
"$ref": "#/responses/forbiddenError"
|
||||||
},
|
},
|
||||||
|
"409": {
|
||||||
|
"$ref": "#/responses/conflictError"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"$ref": "#/responses/internalServerError"
|
"$ref": "#/responses/internalServerError"
|
||||||
}
|
}
|
||||||
|
@ -9632,6 +9635,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": {
|
"/user/helpflags/clear": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
@ -10277,6 +10295,9 @@
|
||||||
"404": {
|
"404": {
|
||||||
"$ref": "#/responses/notFoundError"
|
"$ref": "#/responses/notFoundError"
|
||||||
},
|
},
|
||||||
|
"409": {
|
||||||
|
"$ref": "#/responses/conflictError"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"$ref": "#/responses/internalServerError"
|
"$ref": "#/responses/internalServerError"
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,215 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>{{ Subject .Subject .TemplateData "Verify your new email - {{.Name}}" }}</title>
|
||||||
|
{{ __dangerouslyInjectHTML `<!--[if !mso]><!-->` }}
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
{{ __dangerouslyInjectHTML `<!--<![endif]-->` }}
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
{{ __dangerouslyInjectHTML `<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<![endif]-->` }}
|
||||||
|
{{ __dangerouslyInjectHTML `<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix { width:100% !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->` }}
|
||||||
|
{{ __dangerouslyInjectHTML `<!--[if !mso]><!-->` }}
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Inter" rel="stylesheet" type="text/css">
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Inter);
|
||||||
|
|
||||||
|
</style>
|
||||||
|
{{ __dangerouslyInjectHTML `<!--<![endif]-->` }}
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width:480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<style media="screen and (min-width:480px)">
|
||||||
|
.moz-text-html .mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width:479px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="word-spacing:normal;">
|
||||||
|
<div class="canvas" style="background-color: #fff;">
|
||||||
|
{{ __dangerouslyInjectHTML `<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->` }}
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
{{ __dangerouslyInjectHTML `<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->` }}
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:transparent;vertical-align:top;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:0;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:200px;">
|
||||||
|
<img src="https://grafana.com/static/assets/img/logo_new_transparent_light_400x100.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="200" height="auto">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ __dangerouslyInjectHTML `<!--[if mso | IE]></td></tr></table><![endif]-->` }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ __dangerouslyInjectHTML `<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="background-outlook" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->` }}
|
||||||
|
<div class="background" style="background-color: #FFF; border: 1px solid #e4e5e6; margin: 0px auto; max-width: 600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
{{ __dangerouslyInjectHTML `<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->` }}
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: left; color: #000000;">
|
||||||
|
<h2>Hi {{ .Name }},</h2>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: left; color: #000000;">Please click the following link to verify your email within <strong>{{ .VerificationEmailLifetimeHours }} hour(s)</strong>.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#3D71D9" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#3D71D9;" valign="middle">
|
||||||
|
<a href="{{ .AppUrl }}user/email/update?code={{ .Code }}" rel="noopener" style="display: inline-block; background: #3D71D9; color: #ffffff; font-family: Inter, Helvetica, Arial; font-size: 13px; font-weight: normal; line-height: 120%; margin: 0; text-decoration: none; text-transform: none; padding: 10px 25px; mso-padding-alt: 0px; border-radius: 3px;" target="_blank"> Verify Email </a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: left; color: #000000;">You can also copy and paste this link into your browser directly:</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: left; color: #000000;"><a rel="noopener" href="{{ .AppUrl }}user/email/update?code={{ .Code }}" style="color: #6E9FFF;">{{ .AppUrl }}user/email/update?code={{ .Code }}</a></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ __dangerouslyInjectHTML `<!--[if mso | IE]></td></tr></table><![endif]-->` }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ __dangerouslyInjectHTML `<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->` }}
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
{{ __dangerouslyInjectHTML `<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->` }}
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:transparent;vertical-align:top;" width="100%">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div style="font-family: Inter, Helvetica, Arial; font-size: 13px; line-height: 150%; text-align: center; color: #000000;">© {{ now | date "2006" }} Grafana Labs. Sent by <a href="{{ .AppUrl }}" style="color: #6E9FFF;">Grafana v{{ .BuildVersion }}</a>.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ __dangerouslyInjectHTML `<!--[if mso | IE]></td></tr></table><![endif]-->` }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ __dangerouslyInjectHTML `<!--[if mso | IE]></td></tr></table><![endif]-->` }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -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
|
|
@ -23207,6 +23207,9 @@
|
||||||
"403": {
|
"403": {
|
||||||
"$ref": "#/components/responses/forbiddenError"
|
"$ref": "#/components/responses/forbiddenError"
|
||||||
},
|
},
|
||||||
|
"409": {
|
||||||
|
"$ref": "#/components/responses/conflictError"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"$ref": "#/components/responses/internalServerError"
|
"$ref": "#/components/responses/internalServerError"
|
||||||
}
|
}
|
||||||
|
@ -23241,6 +23244,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": {
|
"/user/helpflags/clear": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "clearHelpFlags",
|
"operationId": "clearHelpFlags",
|
||||||
|
@ -23911,6 +23929,9 @@
|
||||||
"404": {
|
"404": {
|
||||||
"$ref": "#/components/responses/notFoundError"
|
"$ref": "#/components/responses/notFoundError"
|
||||||
},
|
},
|
||||||
|
"409": {
|
||||||
|
"$ref": "#/components/responses/conflictError"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"$ref": "#/components/responses/internalServerError"
|
"$ref": "#/components/responses/internalServerError"
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue