mirror of https://github.com/grafana/grafana
Auth: Passwordless Login Option Using Magic Links (#95436)
* initial passwordless client * passwordless login page * Working basic e2e flow * Add todo comments * Improve the passwordless login flow * improved passwordless login, backend for passwordless signup * add expiration to emails * update email templates & render username & name fields on signup * improve email templates * change login page text while awaiting passwordless code * fix merge conflicts * use claims.TypeUser * add initial passwordless tests * better error messages * simplified error name * remove completed TODOs * linting & minor test improvements & rename passwordless routes * more linting fixes * move code generation to its own func, use locationService to get query params * fix ampersand in email templates & use passwordless api routes in LoginCtrl * txt emails more closely match html email copy * move passwordless auth behind experimental feature toggle * fix PasswordlessLogin property failing typecheck * make update-workspace * user correct placeholder * Update emails/templates/passwordless_verify_existing_user.txt Co-authored-by: Dan Cech <dcech@grafana.com> * Update emails/templates/passwordless_verify_existing_user.mjml Co-authored-by: Dan Cech <dcech@grafana.com> * Update emails/templates/passwordless_verify_new_user.txt Co-authored-by: Dan Cech <dcech@grafana.com> * Update emails/templates/passwordless_verify_new_user.txt Co-authored-by: Dan Cech <dcech@grafana.com> * Update emails/templates/passwordless_verify_new_user.mjml Co-authored-by: Dan Cech <dcech@grafana.com> * use & in email templates * Update emails/templates/passwordless_verify_existing_user.txt Co-authored-by: Dan Cech <dcech@grafana.com> * remove IP address validation * struct for passwordless settings * revert go.work.sum changes * mock locationService.getSearch in failing test --------- Co-authored-by: Mihaly Gyongyosi <mgyongyosi@users.noreply.github.com> Co-authored-by: Dan Cech <dcech@grafana.com>pull/96460/head
parent
c865958292
commit
6abe99efd6
@ -0,0 +1,51 @@ |
||||
<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 email" }} </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-wrapper css-class="background" padding="0"> |
||||
<mj-section padding="0"> |
||||
<mj-column> |
||||
<mj-text> |
||||
<h2>Please verify your email</h2> |
||||
</mj-text> |
||||
<mj-text> |
||||
Copy and paste the confirmation code into the login form to verify your email address. This confirmation code |
||||
will expire in {{ .Expire }} minutes. |
||||
</mj-text> |
||||
</mj-column> |
||||
</mj-section> |
||||
<mj-section padding="10px 25px"> |
||||
<mj-column css-class="well"> |
||||
<mj-text font-size="22px" font-weight="bold" align="center"> {{ .ConfirmationCode }} </mj-text> |
||||
</mj-column> |
||||
</mj-section> |
||||
<mj-section padding="0"> |
||||
<mj-column> |
||||
<mj-text> Alternatively, you can use the button below to verify your email address. </mj-text> |
||||
<mj-button href="{{ .AppUrl }}login/?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}"> |
||||
Verify your 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 }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}" |
||||
>{{ .AppUrl }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}</a |
||||
> |
||||
</mj-text> |
||||
</mj-column> |
||||
</mj-section> |
||||
</mj-wrapper> |
||||
<mj-section> |
||||
<mj-include path="./partials/layout/footer.mjml" /> |
||||
</mj-section> |
||||
</mj-body> |
||||
</mjml> |
@ -0,0 +1,10 @@ |
||||
[[HiddenSubject .Subject "Verify your email"]] |
||||
|
||||
Hi, |
||||
|
||||
Copy and paste the email verification code: |
||||
[[.ConfirmationCode]] |
||||
into the login form to verify your email address. This confirmation code will expire in {{ .Expire }} minutes. |
||||
Alternatively, you can use the button below to verify your email address. |
||||
|
||||
[[.AppUrl]]login/?code=[[.Code]]&confirmationCode=[[.ConfirmationCode]] |
@ -0,0 +1,53 @@ |
||||
<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 "Welcome to Grafana, please complete your sign up!" }} </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-wrapper css-class="background" padding="0"> |
||||
<mj-section padding="0"> |
||||
<mj-column> |
||||
<mj-text> |
||||
<h2>Please complete your signup</h2> |
||||
</mj-text> |
||||
<mj-text> |
||||
Copy and paste the confirmation code into the sign up form to verify your email address. This confirmation |
||||
code will expire in {{ .Expire }} minutes. |
||||
</mj-text> |
||||
</mj-column> |
||||
</mj-section> |
||||
<mj-section padding="10px 25px"> |
||||
<mj-column css-class="well"> |
||||
<mj-text font-size="22px" font-weight="bold" align="center"> {{ .ConfirmationCode }} </mj-text> |
||||
</mj-column> |
||||
</mj-section> |
||||
<mj-section padding="0"> |
||||
<mj-column> |
||||
<mj-text> Alternatively, you can use the button below to complete your sign up. </mj-text> |
||||
<mj-button href="{{ .AppUrl }}login/?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}&signup=true"> |
||||
Complete Sign Up |
||||
</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 }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}&signup=true" |
||||
>{{ .AppUrl }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}&signup=true</a |
||||
> |
||||
</mj-text> |
||||
</mj-column> |
||||
</mj-section> |
||||
</mj-wrapper> |
||||
<mj-section> |
||||
<mj-include path="./partials/layout/footer.mjml" /> |
||||
</mj-section> |
||||
</mj-body> |
||||
</mjml> |
@ -0,0 +1,10 @@ |
||||
[[HiddenSubject .Subject "Welcome to Grafana, please complete your signup!"]] |
||||
|
||||
Hi, |
||||
|
||||
Copy and paste the email verification code: |
||||
[[.ConfirmationCode]] |
||||
into the sign up form to verify your email address. This confirmation code will expire in {{ .Expire }} minutes. |
||||
Alternatively, you can use the button below to verify your email address. |
||||
|
||||
[[.AppUrl]]login/?code=[[.Code]]&confirmationCode=[[.ConfirmationCode]] |
@ -0,0 +1,335 @@ |
||||
package clients |
||||
|
||||
import ( |
||||
"context" |
||||
"crypto/subtle" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"strconv" |
||||
"time" |
||||
|
||||
"github.com/grafana/authlib/claims" |
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil" |
||||
"github.com/grafana/grafana/pkg/infra/log" |
||||
"github.com/grafana/grafana/pkg/infra/remotecache" |
||||
"github.com/grafana/grafana/pkg/services/authn" |
||||
"github.com/grafana/grafana/pkg/services/login" |
||||
"github.com/grafana/grafana/pkg/services/loginattempt" |
||||
"github.com/grafana/grafana/pkg/services/notifications" |
||||
tempuser "github.com/grafana/grafana/pkg/services/temp_user" |
||||
"github.com/grafana/grafana/pkg/services/user" |
||||
"github.com/grafana/grafana/pkg/setting" |
||||
"github.com/grafana/grafana/pkg/util" |
||||
"github.com/grafana/grafana/pkg/web" |
||||
) |
||||
|
||||
var ( |
||||
errPasswordlessClientInvalidConfirmationCode = errutil.Unauthorized("passwordless.invalid.confirmation-code", errutil.WithPublicMessage("Invalid confirmation code")) |
||||
errPasswordlessClientTooManyLoginAttempts = errutil.Unauthorized("passwordless.invalid.login-attempt", errutil.WithPublicMessage("Login temporarily blocked")) |
||||
errPasswordlessClientInvalidEmail = errutil.Unauthorized("passwordless.invalid.email", errutil.WithPublicMessage("Invalid email")) |
||||
errPasswordlessClientCodeAlreadySent = errutil.Unauthorized("passwordless.invalid.code", errutil.WithPublicMessage("Code already sent to email")) |
||||
|
||||
errPasswordlessClientInternal = errutil.Internal("passwordless.failed", errutil.WithPublicMessage("An internal error occurred in the Passwordless client")) |
||||
|
||||
errPasswordlessClientMissingCode = errutil.BadRequest("passwordless.missing.code", errutil.WithPublicMessage("Missing code")) |
||||
) |
||||
|
||||
const passwordlessKeyPrefix = "passwordless-%s" |
||||
|
||||
var _ authn.RedirectClient = new(Passwordless) |
||||
|
||||
func ProvidePasswordless(cfg *setting.Cfg, loginAttempts loginattempt.Service, userService user.Service, tempUserService tempuser.Service, notificationService notifications.Service, cache remotecache.CacheStorage) *Passwordless { |
||||
return &Passwordless{cfg, loginAttempts, userService, tempUserService, notificationService, cache, log.New("authn.passwordless")} |
||||
} |
||||
|
||||
type PasswordlessCacheCodeEntry struct { |
||||
Email string `json:"email"` |
||||
ConfirmationCode string `json:"confirmation_code"` |
||||
SentDate string `json:"sent_date"` |
||||
} |
||||
|
||||
type PasswordlessCacheEmailEntry struct { |
||||
Code string `json:"code"` |
||||
SentDate string `json:"sent_date"` |
||||
} |
||||
|
||||
type Passwordless struct { |
||||
cfg *setting.Cfg |
||||
loginAttempts loginattempt.Service |
||||
userService user.Service |
||||
tempUserService tempuser.Service |
||||
notificationService notifications.Service |
||||
cache remotecache.CacheStorage |
||||
log log.Logger |
||||
} |
||||
|
||||
type EmailForm struct { |
||||
Email string `json:"email" binding:"required,email"` |
||||
} |
||||
|
||||
type PasswordlessForm struct { |
||||
Code string `json:"code" binding:"required"` |
||||
ConfirmationCode string `json:"confirmationCode" binding:"required"` |
||||
Name string `json:"name"` |
||||
Username string `json:"username"` |
||||
} |
||||
|
||||
// Authenticate implements authn.Client.
|
||||
func (c *Passwordless) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identity, error) { |
||||
var form PasswordlessForm |
||||
if err := web.Bind(r.HTTPRequest, &form); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return c.authenticatePasswordless(ctx, r, form) |
||||
} |
||||
|
||||
func (c *Passwordless) generateCodes() (string, string, error) { |
||||
alphabet := []byte("BCDFGHJKLMNPQRSTVWXZ") |
||||
confirmationCode, err := util.GetRandomString(8, alphabet...) |
||||
if err != nil { |
||||
return "", "", err |
||||
} |
||||
code, err := util.GetRandomString(32) |
||||
if err != nil { |
||||
return "", "", err |
||||
} |
||||
return confirmationCode, code, err |
||||
} |
||||
|
||||
// RedirectURL implements authn.RedirectClient.
|
||||
func (c *Passwordless) RedirectURL(ctx context.Context, r *authn.Request) (*authn.Redirect, error) { |
||||
var form EmailForm |
||||
if err := web.Bind(r.HTTPRequest, &form); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// TODO: add IP address validation
|
||||
ok, err := c.loginAttempts.Validate(ctx, form.Email) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if !ok { |
||||
return nil, errPasswordlessClientTooManyLoginAttempts.Errorf("too many consecutive incorrect login attempts for user - login for user temporarily blocked") |
||||
} |
||||
|
||||
err = c.loginAttempts.Add(ctx, form.Email, web.RemoteAddr(r.HTTPRequest)) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
code, err := c.startPasswordless(ctx, form.Email) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return &authn.Redirect{ |
||||
URL: c.cfg.AppSubURL + "/login?code=" + code, |
||||
Extra: map[string]string{"code": code}, |
||||
}, nil |
||||
} |
||||
|
||||
func (c *Passwordless) IsEnabled() bool { |
||||
return true |
||||
} |
||||
|
||||
func (c *Passwordless) Name() string { |
||||
return authn.ClientPasswordless |
||||
} |
||||
|
||||
func (c *Passwordless) startPasswordless(ctx context.Context, email string) (string, error) { |
||||
// 1. check if is existing user with email or user invite with email
|
||||
var existingUser *user.User |
||||
var tempUsers []*tempuser.TempUserDTO |
||||
var err error |
||||
|
||||
if !util.IsEmail(email) { |
||||
return "", errPasswordlessClientInvalidEmail.Errorf("invalid email %s", email) |
||||
} |
||||
|
||||
cacheKey := fmt.Sprintf(passwordlessKeyPrefix, email) |
||||
_, err = c.cache.Get(ctx, cacheKey) |
||||
if err != nil && !errors.Is(err, remotecache.ErrCacheItemNotFound) { |
||||
return "", errPasswordlessClientInternal.Errorf("cache error: %s", err) |
||||
} |
||||
|
||||
// if code already sent to email, return error
|
||||
if err == nil { |
||||
return "", errPasswordlessClientCodeAlreadySent.Errorf("passwordless code already sent to email %s", email) |
||||
} |
||||
|
||||
existingUser, err = c.userService.GetByEmail(ctx, &user.GetUserByEmailQuery{Email: email}) |
||||
if err != nil && !errors.Is(err, user.ErrUserNotFound) { |
||||
return "", errPasswordlessClientInternal.Errorf("error retreiving user by email: %w - email: %s", err, email) |
||||
} |
||||
|
||||
if existingUser == nil { |
||||
tempUsers, err = c.tempUserService.GetTempUsersQuery(ctx, &tempuser.GetTempUsersQuery{Email: email, Status: tempuser.TmpUserInvitePending}) |
||||
|
||||
if err != nil && !errors.Is(err, tempuser.ErrTempUserNotFound) { |
||||
return "", err |
||||
} |
||||
if tempUsers == nil { |
||||
return "", errPasswordlessClientInvalidEmail.Errorf("no user or invite found with email %s", email) |
||||
} |
||||
} |
||||
|
||||
// 2. if existing user or temp user found, send email with passwordless link
|
||||
confirmationCode, code, err := c.generateCodes() |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
emailCmd := notifications.SendEmailCommand{ |
||||
To: []string{email}, |
||||
Data: map[string]any{ |
||||
"Email": email, |
||||
"ConfirmationCode": confirmationCode, |
||||
"Code": code, |
||||
"Expire": c.cfg.PasswordlessMagicLinkAuth.CodeExpiration.Minutes(), |
||||
}, |
||||
} |
||||
|
||||
if existingUser != nil { |
||||
emailCmd.Template = "passwordless_verify_existing_user" |
||||
} else { |
||||
emailCmd.Template = "passwordless_verify_new_user" |
||||
} |
||||
|
||||
err = c.notificationService.SendEmailCommandHandler(ctx, &emailCmd) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
sentDate := time.Now().Format(time.RFC3339) |
||||
|
||||
value := &PasswordlessCacheCodeEntry{ |
||||
Email: email, |
||||
ConfirmationCode: confirmationCode, |
||||
SentDate: sentDate, |
||||
} |
||||
valueBytes, err := json.Marshal(value) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
cacheKey = fmt.Sprintf(passwordlessKeyPrefix, code) |
||||
err = c.cache.Set(ctx, cacheKey, valueBytes, c.cfg.PasswordlessMagicLinkAuth.CodeExpiration) |
||||
if err != nil { |
||||
return "", errPasswordlessClientInternal.Errorf("cache error: %s", err) |
||||
} |
||||
|
||||
// second cache entry to lookup code by email
|
||||
emailValue := &PasswordlessCacheEmailEntry{ |
||||
Code: code, |
||||
SentDate: sentDate, |
||||
} |
||||
valueBytes, err = json.Marshal(emailValue) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
cacheKey = fmt.Sprintf(passwordlessKeyPrefix, email) |
||||
err = c.cache.Set(ctx, cacheKey, valueBytes, c.cfg.PasswordlessMagicLinkAuth.CodeExpiration) |
||||
if err != nil { |
||||
return "", errPasswordlessClientInternal.Errorf("cache error: %s", err) |
||||
} |
||||
|
||||
return code, nil |
||||
} |
||||
|
||||
func (c *Passwordless) authenticatePasswordless(ctx context.Context, r *authn.Request, form PasswordlessForm) (*authn.Identity, error) { |
||||
code := form.Code |
||||
confirmationCode := form.ConfirmationCode |
||||
|
||||
if len(code) == 0 || len(confirmationCode) == 0 { |
||||
return nil, errPasswordlessClientMissingCode.Errorf("no code provided") |
||||
} |
||||
|
||||
cacheKey := fmt.Sprintf(passwordlessKeyPrefix, code) |
||||
jsonData, err := c.cache.Get(ctx, cacheKey) |
||||
if err != nil { |
||||
return nil, errPasswordlessClientInternal.Errorf("cache error: %s", err) |
||||
} |
||||
|
||||
var codeEntry PasswordlessCacheCodeEntry |
||||
err = json.Unmarshal(jsonData, &codeEntry) |
||||
if err != nil { |
||||
return nil, errPasswordlessClientInternal.Errorf("failed to parse entry from passwordless cache: %w - entry: %s", err, string(jsonData)) |
||||
} |
||||
|
||||
if subtle.ConstantTimeCompare([]byte(codeEntry.ConfirmationCode), []byte(confirmationCode)) != 1 { |
||||
return nil, errPasswordlessClientInvalidConfirmationCode |
||||
} |
||||
|
||||
ok, err := c.loginAttempts.Validate(ctx, codeEntry.Email) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if !ok { |
||||
return nil, errPasswordlessClientTooManyLoginAttempts.Errorf("too many consecutive incorrect login attempts for user - login for user temporarily blocked") |
||||
} |
||||
|
||||
if err := c.loginAttempts.Reset(ctx, codeEntry.Email); err != nil { |
||||
c.log.Warn("could not reset login attempts", "err", err, "username", codeEntry.Email) |
||||
} |
||||
|
||||
usr, err := c.userService.GetByEmail(ctx, &user.GetUserByEmailQuery{Email: codeEntry.Email}) |
||||
if err != nil && !errors.Is(err, user.ErrUserNotFound) { |
||||
return nil, errPasswordlessClientInternal.Errorf("error retreiving user by email: %w - email: %s", err, codeEntry.Email) |
||||
} |
||||
|
||||
if usr == nil { |
||||
tempUsers, err := c.tempUserService.GetTempUsersQuery(ctx, &tempuser.GetTempUsersQuery{Email: codeEntry.Email, Status: tempuser.TmpUserInvitePending}) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if tempUsers == nil { |
||||
return nil, errPasswordlessClientInvalidEmail.Errorf("no user or invite found with email %s", codeEntry.Email) |
||||
} |
||||
|
||||
createUserCmd := user.CreateUserCommand{ |
||||
Email: codeEntry.Email, |
||||
Login: form.Username, |
||||
Name: form.Name, |
||||
} |
||||
|
||||
// TODO: use user sync hook to create user
|
||||
usr, err = c.userService.Create(ctx, &createUserCmd) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
for _, tempUser := range tempUsers { |
||||
if err := c.tempUserService.UpdateTempUserStatus(ctx, &tempuser.UpdateTempUserStatusCommand{Code: tempUser.Code, Status: tempuser.TmpUserCompleted}); err != nil { |
||||
return nil, err |
||||
} |
||||
} |
||||
} |
||||
|
||||
// delete cache entry with code as key
|
||||
err = c.cache.Delete(ctx, cacheKey) |
||||
if err != nil { |
||||
return nil, errPasswordlessClientInternal.Errorf("failed to delete entry from passwordless cache: %w - key: %s", err, cacheKey) |
||||
} |
||||
|
||||
// delete cache entry with email as key
|
||||
cacheKey = fmt.Sprintf(passwordlessKeyPrefix, codeEntry.Email) |
||||
err = c.cache.Delete(ctx, cacheKey) |
||||
if err != nil { |
||||
return nil, errPasswordlessClientInternal.Errorf("failed to delete entry from passwordless cache: %w - key: %s", err, cacheKey) |
||||
} |
||||
|
||||
// user was found so set auth module in req metadata
|
||||
r.SetMeta(authn.MetaKeyAuthModule, login.PasswordlessAuthModule) |
||||
|
||||
return &authn.Identity{ |
||||
ID: strconv.FormatInt(usr.ID, 10), |
||||
Type: claims.TypeUser, |
||||
OrgID: r.OrgID, |
||||
ClientParams: authn.ClientParams{FetchSyncedUser: true, SyncPermissions: true}, |
||||
AuthenticatedBy: login.PasswordlessAuthModule, |
||||
}, nil |
||||
} |
@ -0,0 +1,160 @@ |
||||
package clients |
||||
|
||||
import ( |
||||
"context" |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/assert" |
||||
|
||||
"github.com/grafana/authlib/claims" |
||||
"github.com/grafana/grafana/pkg/infra/remotecache" |
||||
"github.com/grafana/grafana/pkg/services/authn" |
||||
"github.com/grafana/grafana/pkg/services/login" |
||||
"github.com/grafana/grafana/pkg/services/loginattempt/loginattempttest" |
||||
"github.com/grafana/grafana/pkg/services/notifications" |
||||
tempuser "github.com/grafana/grafana/pkg/services/temp_user" |
||||
"github.com/grafana/grafana/pkg/services/temp_user/tempusertest" |
||||
"github.com/grafana/grafana/pkg/services/user" |
||||
"github.com/grafana/grafana/pkg/services/user/usertest" |
||||
"github.com/grafana/grafana/pkg/setting" |
||||
"github.com/grafana/grafana/pkg/util" |
||||
) |
||||
|
||||
func TestPasswordless_StartPasswordless(t *testing.T) { |
||||
type testCase struct { |
||||
desc string |
||||
email string |
||||
findUser bool |
||||
findTempUser bool |
||||
blockLogin bool |
||||
expectedErr error |
||||
} |
||||
|
||||
tests := []testCase{ |
||||
{ |
||||
desc: "should succeed if user is found", |
||||
email: "user@domain.com", |
||||
findUser: true, |
||||
blockLogin: false, |
||||
}, |
||||
{ |
||||
desc: "should succeed if temp user is found", |
||||
email: "user@domain.com", |
||||
findUser: false, |
||||
findTempUser: true, |
||||
blockLogin: false, |
||||
}, |
||||
{ |
||||
desc: "should fail if user or temp user is not found", |
||||
email: "user@domain.com", |
||||
findUser: false, |
||||
findTempUser: false, |
||||
blockLogin: false, |
||||
expectedErr: errPasswordlessClientInvalidEmail.Errorf("no user or invite found with email user@domain.com"), |
||||
}, |
||||
} |
||||
|
||||
for _, tt := range tests { |
||||
t.Run(tt.desc, func(t *testing.T) { |
||||
hashed, _ := util.EncodePassword("password", "salt") |
||||
userService := &usertest.FakeUserService{ |
||||
ExpectedUser: &user.User{ID: 1, Email: "user@domain.com", Login: "user", Password: user.Password(hashed), Salt: "salt"}, |
||||
} |
||||
las := &loginattempttest.FakeLoginAttemptService{ExpectedValid: !tt.blockLogin} |
||||
tus := &tempusertest.FakeTempUserService{} |
||||
tus.GetTempUsersQueryFN = func(ctx context.Context, query *tempuser.GetTempUsersQuery) ([]*tempuser.TempUserDTO, error) { |
||||
return []*tempuser.TempUserDTO{{ |
||||
ID: 1, |
||||
Email: "user@domain.com", |
||||
Status: tempuser.TmpUserInvitePending, |
||||
EmailSent: true, |
||||
}}, nil |
||||
} |
||||
ns := notifications.MockNotificationService() |
||||
cache := remotecache.NewFakeCacheStorage() |
||||
|
||||
if !tt.findUser { |
||||
userService.ExpectedUser = nil |
||||
userService.ExpectedError = user.ErrUserNotFound |
||||
} |
||||
|
||||
if !tt.findTempUser { |
||||
tus.GetTempUsersQueryFN = func(ctx context.Context, query *tempuser.GetTempUsersQuery) ([]*tempuser.TempUserDTO, error) { |
||||
return nil, tempuser.ErrTempUserNotFound |
||||
} |
||||
} |
||||
|
||||
c := ProvidePasswordless(setting.NewCfg(), las, userService, tus, ns, cache) |
||||
_, err := c.startPasswordless(context.Background(), tt.email) |
||||
assert.ErrorIs(t, err, tt.expectedErr) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestPasswordless_AuthenticatePasswordless(t *testing.T) { |
||||
type testCase struct { |
||||
desc string |
||||
email string |
||||
findUser bool |
||||
blockLogin bool |
||||
expectedErr error |
||||
expectedIdentity *authn.Identity |
||||
} |
||||
|
||||
tests := []testCase{ |
||||
{ |
||||
desc: "should successfully authenticate user with correct passwordless magic link", |
||||
email: "user@domain.com", |
||||
findUser: true, |
||||
blockLogin: false, |
||||
expectedIdentity: &authn.Identity{ |
||||
ID: "1", |
||||
Type: claims.TypeUser, |
||||
OrgID: 1, |
||||
AuthenticatedBy: login.PasswordlessAuthModule, |
||||
ClientParams: authn.ClientParams{FetchSyncedUser: true, SyncPermissions: true}, |
||||
}, |
||||
}, |
||||
{ |
||||
desc: "should fail if login is blocked", |
||||
email: "user@domain.com", |
||||
findUser: true, |
||||
blockLogin: true, |
||||
expectedErr: errPasswordlessClientTooManyLoginAttempts.Errorf("too many consecutive incorrect login attempts for user - login for user temporarily blocked"), |
||||
}, |
||||
} |
||||
|
||||
for _, tt := range tests { |
||||
t.Run(tt.desc, func(t *testing.T) { |
||||
hashed, _ := util.EncodePassword("password", "salt") |
||||
userService := &usertest.FakeUserService{ |
||||
ExpectedUser: &user.User{ID: 1, Email: "user@domain.com", Login: "user", Password: user.Password(hashed), Salt: "salt"}, |
||||
} |
||||
las := &loginattempttest.FakeLoginAttemptService{ExpectedValid: !tt.blockLogin} |
||||
tus := &tempusertest.FakeTempUserService{} |
||||
ns := notifications.MockNotificationService() |
||||
cache := remotecache.NewFakeCacheStorage() |
||||
|
||||
if !tt.findUser { |
||||
userService.ExpectedUser = nil |
||||
userService.ExpectedError = user.ErrUserNotFound |
||||
} |
||||
|
||||
c := ProvidePasswordless(setting.NewCfg(), las, userService, tus, ns, cache) |
||||
code, err := c.startPasswordless(context.Background(), tt.email) |
||||
if err != nil { |
||||
t.Fatalf("failed to start passwordless: %v", err) |
||||
} |
||||
|
||||
form := &PasswordlessForm{ |
||||
Code: code, |
||||
ConfirmationCode: ns.Email.Data["ConfirmationCode"].(string), |
||||
Name: "user", |
||||
Username: "username", |
||||
} |
||||
identity, err := c.authenticatePasswordless(context.Background(), &authn.Request{OrgID: 1}, *form) |
||||
assert.ErrorIs(t, err, tt.expectedErr) |
||||
assert.EqualValues(t, tt.expectedIdentity, identity) |
||||
}) |
||||
} |
||||
} |
|
@ -0,0 +1,17 @@ |
||||
package setting |
||||
|
||||
import "time" |
||||
|
||||
type AuthPasswordlessMagicLinkSettings struct { |
||||
// Passwordless Auth via Magic Link
|
||||
Enabled bool |
||||
CodeExpiration time.Duration |
||||
} |
||||
|
||||
func (cfg *Cfg) readPasswordlessMagicLinkSettings() { |
||||
authPasswordless := cfg.SectionWithEnvOverrides("auth.passwordless") |
||||
PasswordlessMagicLinkSettings := AuthPasswordlessMagicLinkSettings{} |
||||
PasswordlessMagicLinkSettings.Enabled = authPasswordless.Key("enabled").MustBool(false) |
||||
PasswordlessMagicLinkSettings.CodeExpiration = authPasswordless.Key("code_expiration").MustDuration(time.Minute * 20) |
||||
cfg.PasswordlessMagicLinkAuth = PasswordlessMagicLinkSettings |
||||
} |
@ -0,0 +1,135 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { useId, useEffect, useState } from 'react'; |
||||
import { useForm } from 'react-hook-form'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
import { locationService } from '@grafana/runtime'; |
||||
import { Button, Input, Field, useStyles2 } from '@grafana/ui'; |
||||
import { Branding } from 'app/core/components/Branding/Branding'; |
||||
import { t } from 'app/core/internationalization'; |
||||
|
||||
import { PasswordlessConfirmationFormModel } from './LoginCtrl'; |
||||
|
||||
interface Props { |
||||
onSubmit: (data: PasswordlessConfirmationFormModel) => void; |
||||
isLoggingIn: boolean; |
||||
} |
||||
|
||||
export const PasswordlessConfirmation = ({ onSubmit, isLoggingIn }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
const confirmationCodeId = useId(); |
||||
const codeId = useId(); |
||||
const usernameId = useId(); |
||||
const nameId = useId(); |
||||
const [signup, setSignup] = useState(false); |
||||
|
||||
const { |
||||
handleSubmit, |
||||
register, |
||||
setValue, |
||||
formState: { errors }, |
||||
} = useForm<PasswordlessConfirmationFormModel>({ mode: 'onChange' }); |
||||
|
||||
useEffect(() => { |
||||
Branding.LoginTitle = "We've sent you an email!"; |
||||
Branding.GetLoginSubTitle = () => |
||||
"Check your inbox and click the confirmation link or use the confirmation code we've sent."; |
||||
|
||||
const queryValues = locationService.getSearch(); |
||||
|
||||
setValue('code', queryValues.get('code') || ''); |
||||
if (queryValues.get('confirmationCode')) { |
||||
setValue('confirmationCode', queryValues.get('confirmationCode') || ''); |
||||
if (!queryValues.get('signup')) { |
||||
handleSubmit(onSubmit)(); |
||||
} |
||||
} |
||||
if (queryValues.get('signup')) { |
||||
setSignup(true); |
||||
} |
||||
if (queryValues.get('username')) { |
||||
setValue('username', queryValues.get('username') || ''); |
||||
} |
||||
if (queryValues.get('name')) { |
||||
setValue('name', queryValues.get('name') || ''); |
||||
} |
||||
}, [setValue, handleSubmit, onSubmit, setSignup]); |
||||
|
||||
return ( |
||||
<div className={styles.wrapper}> |
||||
<form onSubmit={handleSubmit(onSubmit)}> |
||||
<Field hidden={true}> |
||||
<Input {...register('code')} id={codeId} hidden={true} /> |
||||
</Field> |
||||
<Field |
||||
label={t('login.form.confirmation-code-label', 'Confirmation code')} |
||||
invalid={!!errors.code} |
||||
error={errors.code?.message} |
||||
> |
||||
<Input |
||||
{...register('confirmationCode', { |
||||
required: t('login.form.confirmation-code', 'Confirmation code is required'), |
||||
})} |
||||
id={confirmationCodeId} |
||||
autoFocus |
||||
autoCapitalize="none" |
||||
placeholder={t('login.form.confirmation-code-placeholder', 'confirmation code')} |
||||
data-testid={selectors.pages.PasswordlessLogin.email} |
||||
/> |
||||
</Field> |
||||
{signup && ( |
||||
<> |
||||
<Field label={'Username'} invalid={!!errors.code} error={errors.code?.message} hidden={true}> |
||||
<Input |
||||
{...register('username')} |
||||
id={usernameId} |
||||
autoFocus |
||||
autoCapitalize="none" |
||||
placeholder={'username'} |
||||
data-testid={selectors.pages.PasswordlessLogin.email} |
||||
hidden={true} |
||||
/> |
||||
</Field> |
||||
<Field label={t('login.form.name-label', 'Name')} invalid={!!errors.code} error={errors.code?.message}> |
||||
<Input |
||||
{...register('name')} |
||||
id={nameId} |
||||
autoFocus |
||||
autoCapitalize="none" |
||||
placeholder={t('login.form.name-placeholder', 'name')} |
||||
data-testid={selectors.pages.PasswordlessLogin.email} |
||||
/> |
||||
</Field> |
||||
</> |
||||
)} |
||||
<Button |
||||
type="submit" |
||||
data-testid={selectors.pages.Login.submit} |
||||
className={styles.submitButton} |
||||
disabled={isLoggingIn} |
||||
> |
||||
{isLoggingIn ? t('login.form.submit-loading-label', 'Logging in...') : t('login.form.submit-label', 'Log in')} |
||||
</Button> |
||||
</form> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
wrapper: css({ |
||||
width: '100%', |
||||
paddingBottom: theme.spacing(2), |
||||
}), |
||||
|
||||
submitButton: css({ |
||||
justifyContent: 'center', |
||||
width: '100%', |
||||
}), |
||||
|
||||
skipButton: css({ |
||||
alignSelf: 'flex-start', |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,70 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { useId } from 'react'; |
||||
import { useForm } from 'react-hook-form'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
import { Button, Input, Field, useStyles2 } from '@grafana/ui'; |
||||
import { t } from 'app/core/internationalization'; |
||||
|
||||
import { PasswordlessFormModel } from './LoginCtrl'; |
||||
|
||||
interface Props { |
||||
onSubmit: (data: PasswordlessFormModel) => void; |
||||
isLoggingIn: boolean; |
||||
} |
||||
|
||||
export const PasswordlessLoginForm = ({ onSubmit, isLoggingIn }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
const emailId = useId(); |
||||
const { |
||||
handleSubmit, |
||||
register, |
||||
formState: { errors }, |
||||
} = useForm<PasswordlessFormModel>({ mode: 'onChange' }); |
||||
|
||||
return ( |
||||
<div className={styles.wrapper}> |
||||
<form onSubmit={handleSubmit(onSubmit)}> |
||||
<Field label={t('login.form.email-label', 'Email')} invalid={!!errors.email} error={errors.email?.message}> |
||||
<Input |
||||
{...register('email', { required: t('login.form.email-required', 'Email is required') })} |
||||
id={emailId} |
||||
autoFocus |
||||
autoCapitalize="none" |
||||
placeholder={t('login.form.email-placeholder', 'email')} |
||||
data-testid={selectors.pages.PasswordlessLogin.email} |
||||
/> |
||||
</Field> |
||||
<Button |
||||
type="submit" |
||||
data-testid={selectors.pages.Login.submit} |
||||
className={styles.submitButton} |
||||
disabled={isLoggingIn} |
||||
> |
||||
{isLoggingIn |
||||
? t('login.form.verify-email-loading-label', 'Sending email...') |
||||
: t('login.form.verify-email-label', 'Send a verification email')} |
||||
</Button> |
||||
</form> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
wrapper: css({ |
||||
width: '100%', |
||||
paddingBottom: theme.spacing(2), |
||||
}), |
||||
|
||||
submitButton: css({ |
||||
justifyContent: 'center', |
||||
width: '100%', |
||||
}), |
||||
|
||||
skipButton: css({ |
||||
alignSelf: 'flex-start', |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,273 @@ |
||||
<!doctype html> |
||||
<html lang="und" dir="auto" 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 email" }}</title> |
||||
<!--[if !mso]><!--> |
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
||||
<!--<![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> |
||||
<!--[if mso]> |
||||
<noscript> |
||||
<xml> |
||||
<o:OfficeDocumentSettings> |
||||
<o:AllowPNG/> |
||||
<o:PixelsPerInch>96</o:PixelsPerInch> |
||||
</o:OfficeDocumentSettings> |
||||
</xml> |
||||
</noscript> |
||||
<![endif]--> |
||||
<!--[if lte mso 11]> |
||||
<style type="text/css"> |
||||
.mj-outlook-group-fix { width:100% !important; } |
||||
</style> |
||||
<![endif]--> |
||||
<!--[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> |
||||
<!--<![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> |
||||
</head> |
||||
|
||||
<body style="word-spacing:normal;"> |
||||
<div class="canvas" style="background-color: #fff;" lang="und" dir="auto"> |
||||
<!--[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;"> |
||||
<!--[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 alt 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> |
||||
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[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:0;text-align:center;"> |
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="600px" ><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:0;text-align:center;"> |
||||
<!--[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>Please verify your email</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;">Copy and paste the confirmation code in the login form to verify your email address. This confirmation code will expire in {{ .Expire }} minutes.</div> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><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:10px 25px;text-align:center;"> |
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="well-outlook" style="vertical-align:top;width:550px;" ><![endif]--> |
||||
<div class="mj-column-per-100 mj-outlook-group-fix well" style="background-color: #F4F5F5; border: 1px solid #e4e5e6; 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="center" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||
<div style="font-family: Inter, Helvetica, Arial; font-size: 22px; font-weight: bold; line-height: 150%; text-align: center; color: #000000;">{{ .ConfirmationCode }}</div> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><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:0;text-align:center;"> |
||||
<!--[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;">Alternatively, you can use the button below to verify your email address.</div> |
||||
</td> |
||||
</tr> |
||||
<tr> |
||||
<td align="center" 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 }}login/?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}" 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 your 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 }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}" style="color: #6E9FFF;">{{ .AppUrl }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}</a></div> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]--> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[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;"> |
||||
<!--[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> |
||||
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||
</div> |
||||
</body> |
||||
|
||||
</html> |
@ -0,0 +1,12 @@ |
||||
[[HiddenSubject .Subject "Verify your email"]] |
||||
|
||||
Hi, |
||||
|
||||
Copy and paste the confirmation code in the login form to verify your email address. |
||||
|
||||
Copy and paste the email verification code: |
||||
[[.ConfirmationCode]] |
||||
in the in the login form to verify your email address. This confirmation code will expire in {{ .Expire }} minutes. |
||||
Alternatively, you can use the button below to verify your email address. |
||||
|
||||
[[.AppUrl]]login/?code=[[.Code]]&confirmationCode=[[.ConfirmationCode]] |
@ -0,0 +1,273 @@ |
||||
<!doctype html> |
||||
<html lang="und" dir="auto" 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 "Welcome to Grafana, please complete your sign up!" }}</title> |
||||
<!--[if !mso]><!--> |
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
||||
<!--<![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> |
||||
<!--[if mso]> |
||||
<noscript> |
||||
<xml> |
||||
<o:OfficeDocumentSettings> |
||||
<o:AllowPNG/> |
||||
<o:PixelsPerInch>96</o:PixelsPerInch> |
||||
</o:OfficeDocumentSettings> |
||||
</xml> |
||||
</noscript> |
||||
<![endif]--> |
||||
<!--[if lte mso 11]> |
||||
<style type="text/css"> |
||||
.mj-outlook-group-fix { width:100% !important; } |
||||
</style> |
||||
<![endif]--> |
||||
<!--[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> |
||||
<!--<![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> |
||||
</head> |
||||
|
||||
<body style="word-spacing:normal;"> |
||||
<div class="canvas" style="background-color: #fff;" lang="und" dir="auto"> |
||||
<!--[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;"> |
||||
<!--[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 alt 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> |
||||
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[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:0;text-align:center;"> |
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="600px" ><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:0;text-align:center;"> |
||||
<!--[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>Please complete your signup</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;">Copy and paste the confirmation code in the sign up form to verify your email address. This confirmation code will expire in {{ .Expire }} minutes.</div> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><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:10px 25px;text-align:center;"> |
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="well-outlook" style="vertical-align:top;width:550px;" ><![endif]--> |
||||
<div class="mj-column-per-100 mj-outlook-group-fix well" style="background-color: #F4F5F5; border: 1px solid #e4e5e6; 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="center" class="txt" style="font-size:0px;padding:10px 25px;word-break:break-word;"> |
||||
<div style="font-family: Inter, Helvetica, Arial; font-size: 22px; font-weight: bold; line-height: 150%; text-align: center; color: #000000;">{{ .ConfirmationCode }}</div> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><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:0;text-align:center;"> |
||||
<!--[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;">Alternatively, you can use the button below to complete your sign up.</div> |
||||
</td> |
||||
</tr> |
||||
<tr> |
||||
<td align="center" 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 }}login/?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}&signup=true" 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"> Complete Sign Up </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 }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}&signup=true" style="color: #6E9FFF;">{{ .AppUrl }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}&signup=true</a></div> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]--> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[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;"> |
||||
<!--[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> |
||||
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
<!--[if mso | IE]></td></tr></table><![endif]--> |
||||
</div> |
||||
</body> |
||||
|
||||
</html> |
@ -0,0 +1,12 @@ |
||||
[[HiddenSubject .Subject "Welcome to Grafana, please complete your signup!"]] |
||||
|
||||
Hi, |
||||
|
||||
Copy and paste the confirmation code in the login form to verify your email address. |
||||
|
||||
Copy and paste the email verification code: |
||||
[[.ConfirmationCode]] |
||||
in the in the login form to verify your email address. This confirmation code will expire in {{ .Expire }} minutes. |
||||
Alternatively, you can use the button below to verify your email address. |
||||
|
||||
[[.AppUrl]]login/?code=[[.Code]]&confirmationCode=[[.ConfirmationCode]] |
Loading…
Reference in new issue