From 6abe99efd64b8867112d9d9c74971d96840ce32d Mon Sep 17 00:00:00 2001 From: colin-stuart Date: Thu, 14 Nov 2024 08:50:55 -0500 Subject: [PATCH] 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 * Update emails/templates/passwordless_verify_existing_user.mjml Co-authored-by: Dan Cech * Update emails/templates/passwordless_verify_new_user.txt Co-authored-by: Dan Cech * Update emails/templates/passwordless_verify_new_user.txt Co-authored-by: Dan Cech * Update emails/templates/passwordless_verify_new_user.mjml Co-authored-by: Dan Cech * use & in email templates * Update emails/templates/passwordless_verify_existing_user.txt Co-authored-by: Dan Cech * remove IP address validation * struct for passwordless settings * revert go.work.sum changes * mock locationService.getSearch in failing test --------- Co-authored-by: Mihaly Gyongyosi Co-authored-by: Dan Cech --- conf/defaults.ini | 5 + .../passwordless_verify_existing_user.mjml | 51 +++ .../passwordless_verify_existing_user.txt | 10 + .../passwordless_verify_new_user.mjml | 53 +++ .../passwordless_verify_new_user.txt | 10 + packages/grafana-data/src/types/config.ts | 1 + .../src/types/featureToggles.gen.ts | 1 + .../src/selectors/pages.ts | 13 + pkg/api/api.go | 6 + pkg/api/dtos/frontend_settings.go | 2 + pkg/api/frontendsettings.go | 1 + pkg/api/login.go | 20 ++ pkg/services/authn/authn.go | 21 +- pkg/services/authn/authnimpl/registration.go | 9 +- pkg/services/authn/clients/passwordless.go | 335 ++++++++++++++++++ .../authn/clients/passwordless_test.go | 160 +++++++++ pkg/services/featuremgmt/registry.go | 9 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 14 + pkg/services/login/authinfo.go | 17 +- pkg/services/temp_user/tempusertest/fake.go | 8 + pkg/setting/setting.go | 3 + .../setting_passwordless_magic_link.go | 17 + .../ChangePasswordPage.test.tsx | 3 + .../app/core/components/Login/LoginCtrl.tsx | 71 +++- .../app/core/components/Login/LoginPage.tsx | 23 +- .../Login/PasswordlessConfirmationForm.tsx | 135 +++++++ .../Login/PasswordlessLoginForm.tsx | 70 ++++ public/app/core/components/Login/types.ts | 4 + .../passwordless_verify_existing_user.html | 273 ++++++++++++++ .../passwordless_verify_existing_user.txt | 12 + .../emails/passwordless_verify_new_user.html | 273 ++++++++++++++ .../emails/passwordless_verify_new_user.txt | 12 + public/locales/en-US/grafana.json | 12 +- public/locales/pseudo-LOCALE/grafana.json | 12 +- 36 files changed, 1644 insertions(+), 27 deletions(-) create mode 100644 emails/templates/passwordless_verify_existing_user.mjml create mode 100644 emails/templates/passwordless_verify_existing_user.txt create mode 100644 emails/templates/passwordless_verify_new_user.mjml create mode 100644 emails/templates/passwordless_verify_new_user.txt create mode 100644 pkg/services/authn/clients/passwordless.go create mode 100644 pkg/services/authn/clients/passwordless_test.go create mode 100644 pkg/setting/setting_passwordless_magic_link.go create mode 100644 public/app/core/components/Login/PasswordlessConfirmationForm.tsx create mode 100644 public/app/core/components/Login/PasswordlessLoginForm.tsx create mode 100644 public/emails/passwordless_verify_existing_user.html create mode 100644 public/emails/passwordless_verify_existing_user.txt create mode 100644 public/emails/passwordless_verify_new_user.html create mode 100644 public/emails/passwordless_verify_new_user.txt diff --git a/conf/defaults.ini b/conf/defaults.ini index ad60ca5f9a0..4ee34159a96 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -626,6 +626,11 @@ id_response_header_namespaces = user api-key service-account # This feature currently **only supports single-organization deployments** managed_service_accounts_enabled = false +#################################### Passwordless Auth ########################### +[auth.passwordless] +enabled = false +code_expiration = 20m + #################################### SSO Settings ########################### [sso_settings] # interval for reloading the SSO Settings from the database diff --git a/emails/templates/passwordless_verify_existing_user.mjml b/emails/templates/passwordless_verify_existing_user.mjml new file mode 100644 index 00000000000..1c583bc03ad --- /dev/null +++ b/emails/templates/passwordless_verify_existing_user.mjml @@ -0,0 +1,51 @@ + + + + + + + + {{ Subject .Subject .TemplateData "Verify your email" }} + + + + + + + + + + +

Please verify your email

+
+ + Copy and paste the confirmation code into the login form to verify your email address. This confirmation code + will expire in {{ .Expire }} minutes. + +
+
+ + + {{ .ConfirmationCode }} + + + + + Alternatively, you can use the button below to verify your email address. + + Verify your email + + You can also copy and paste this link into your browser directly: + + {{ .AppUrl }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }} + + + +
+ + + +
+
diff --git a/emails/templates/passwordless_verify_existing_user.txt b/emails/templates/passwordless_verify_existing_user.txt new file mode 100644 index 00000000000..ccc8f1f3d1d --- /dev/null +++ b/emails/templates/passwordless_verify_existing_user.txt @@ -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]] diff --git a/emails/templates/passwordless_verify_new_user.mjml b/emails/templates/passwordless_verify_new_user.mjml new file mode 100644 index 00000000000..d08457ea7e5 --- /dev/null +++ b/emails/templates/passwordless_verify_new_user.mjml @@ -0,0 +1,53 @@ + + + + + + + + {{ Subject .Subject .TemplateData "Welcome to Grafana, please complete your sign up!" }} + + + + + + + + + + +

Please complete your signup

+
+ + Copy and paste the confirmation code into the sign up form to verify your email address. This confirmation + code will expire in {{ .Expire }} minutes. + +
+
+ + + {{ .ConfirmationCode }} + + + + + Alternatively, you can use the button below to complete your sign up. + + Complete Sign Up + + You can also copy and paste this link into your browser directly: + + {{ .AppUrl }}login?code={{ .Code }}&confirmationCode={{ .ConfirmationCode }}&signup=true + + + +
+ + + +
+
diff --git a/emails/templates/passwordless_verify_new_user.txt b/emails/templates/passwordless_verify_new_user.txt new file mode 100644 index 00000000000..938d1dab934 --- /dev/null +++ b/emails/templates/passwordless_verify_new_user.txt @@ -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]] diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index ceda30f6674..50b9461ba19 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -278,5 +278,6 @@ export interface AuthSettings { GenericOAuthSkipOrgRoleSync?: boolean; disableLogin?: boolean; + passwordlessEnabled?: boolean; basicAuthStrongPasswordPolicy?: boolean; } diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index d3dbd6c2c7f..cd555753f3f 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -232,6 +232,7 @@ export interface FeatureToggles { preinstallAutoUpdate?: boolean; dashboardSchemaV2?: boolean; playlistsWatcher?: boolean; + passwordlessMagicLinkAuthentication?: boolean; exploreMetricsRelatedLogs?: boolean; enableExtensionsAdminPage?: boolean; zipkinBackendMigration?: boolean; diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 3d84faa1538..92f73b85303 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -39,6 +39,19 @@ export const versionedPages = { '10.2.3': 'data-testid Skip change password button', }, }, + PasswordlessLogin: { + url: { + [MIN_GRAFANA_VERSION]: '/login/passwordless/authenticate', + }, + email: { + '10.2.3': 'data-testid Email input field', + [MIN_GRAFANA_VERSION]: 'Email input field', + }, + submit: { + '10.2.3': 'data-testid PasswordlessLogin button', + [MIN_GRAFANA_VERSION]: 'PasswordlessLogin button', + }, + }, Home: { url: { [MIN_GRAFANA_VERSION]: '/', diff --git a/pkg/api/api.go b/pkg/api/api.go index 968aae01a84..adf60b406d7 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -78,6 +78,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/logout", hs.Logout) r.Post("/login", requestmeta.SetOwner(requestmeta.TeamAuth), quota(string(auth.QuotaTargetSrv)), routing.Wrap(hs.LoginPost)) r.Get("/login/:name", quota(string(auth.QuotaTargetSrv)), hs.OAuthLogin) + r.Get("/login", hs.LoginView) r.Get("/invite/:code", hs.Index) @@ -207,6 +208,11 @@ func (hs *HTTPServer) registerRoutes() { r.Post("/api/user/email/start-verify", reqSignedInNoAnonymous, routing.Wrap(hs.StartEmailVerificaton)) } + if hs.Cfg.PasswordlessMagicLinkAuth.Enabled && hs.Features.IsEnabledGlobally(featuremgmt.FlagPasswordlessMagicLinkAuthentication) { + r.Post("/api/login/passwordless/start", requestmeta.SetOwner(requestmeta.TeamAuth), quota(string(auth.QuotaTargetSrv)), hs.StartPasswordless) + r.Post("/api/login/passwordless/authenticate", requestmeta.SetOwner(requestmeta.TeamAuth), quota(string(auth.QuotaTargetSrv)), routing.Wrap(hs.LoginPasswordless)) + } + // invited r.Get("/api/user/invite/:code", routing.Wrap(hs.GetInviteInfoByCode)) r.Post("/api/user/invite/complete", routing.Wrap(hs.CompleteInvite)) diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index 0799c4814fe..37a71629fde 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -33,6 +33,7 @@ type FrontendSettingsAuthDTO struct { DisableLogin bool `json:"disableLogin"` BasicAuthStrongPasswordPolicy bool `json:"basicAuthStrongPasswordPolicy"` + PasswordlessEnabled bool `json:"passwordlessEnabled"` } type FrontendSettingsBuildInfoDTO struct { @@ -253,6 +254,7 @@ type FrontendSettingsDTO struct { TokenExpirationDayLimit int `json:"tokenExpirationDayLimit"` SharedWithMeFolderUID string `json:"sharedWithMeFolderUID"` RootFolderUID string `json:"rootFolderUID"` + PasswordlessEnabled string `json:"passwordlessEnabled"` GeomapDefaultBaseLayerConfig *map[string]any `json:"geomapDefaultBaseLayerConfig,omitempty"` GeomapDisableCustomBaseLayer bool `json:"geomapDisableCustomBaseLayer"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 3441ec56f0e..002bbbfd54c 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -360,6 +360,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro OktaSkipOrgRoleSync: parseSkipOrgRoleSyncEnabled(oauthProviders[social.OktaProviderName]), DisableLogin: hs.Cfg.DisableLogin, BasicAuthStrongPasswordPolicy: hs.Cfg.BasicAuthStrongPasswordPolicy, + PasswordlessEnabled: hs.Cfg.PasswordlessMagicLinkAuth.Enabled && hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagPasswordlessMagicLinkAuthentication), } if hs.pluginsCDNService != nil && hs.pluginsCDNService.IsEnabled() { diff --git a/pkg/api/login.go b/pkg/api/login.go index 338ccf5a24e..0361b133872 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -241,6 +241,26 @@ func (hs *HTTPServer) LoginPost(c *contextmodel.ReqContext) response.Response { return authn.HandleLoginResponse(c.Req, c.Resp, hs.Cfg, identity, hs.ValidateRedirectTo, hs.Features) } +func (hs *HTTPServer) LoginPasswordless(c *contextmodel.ReqContext) response.Response { + identity, err := hs.authnService.Login(c.Req.Context(), authn.ClientPasswordless, &authn.Request{HTTPRequest: c.Req}) + if err != nil { + tokenErr := &auth.CreateTokenErr{} + if errors.As(err, &tokenErr) { + return response.Error(tokenErr.StatusCode, tokenErr.ExternalErr, tokenErr.InternalErr) + } + return response.Err(err) + } + return authn.HandleLoginResponse(c.Req, c.Resp, hs.Cfg, identity, hs.ValidateRedirectTo, hs.Features) +} + +func (hs *HTTPServer) StartPasswordless(c *contextmodel.ReqContext) { + redirect, err := hs.authnService.RedirectURL(c.Req.Context(), authn.ClientPasswordless, &authn.Request{HTTPRequest: c.Req}) + if err != nil { + c.Redirect(hs.redirectURLWithErrorCookie(c, err)) + } + c.JSON(http.StatusOK, redirect) +} + func (hs *HTTPServer) loginUserWithUser(user *user.User, c *contextmodel.ReqContext) error { if user == nil { return errors.New("could not login user") diff --git a/pkg/services/authn/authn.go b/pkg/services/authn/authn.go index a689fdfa0f7..5dcb9d45f5f 100644 --- a/pkg/services/authn/authn.go +++ b/pkg/services/authn/authn.go @@ -19,16 +19,17 @@ import ( ) const ( - ClientAPIKey = "auth.client.api-key" // #nosec G101 - ClientAnonymous = "auth.client.anonymous" - ClientBasic = "auth.client.basic" - ClientJWT = "auth.client.jwt" - ClientExtendedJWT = "auth.client.extended-jwt" - ClientRender = "auth.client.render" - ClientSession = "auth.client.session" - ClientForm = "auth.client.form" - ClientProxy = "auth.client.proxy" - ClientSAML = "auth.client.saml" + ClientAPIKey = "auth.client.api-key" // #nosec G101 + ClientAnonymous = "auth.client.anonymous" + ClientBasic = "auth.client.basic" + ClientJWT = "auth.client.jwt" + ClientExtendedJWT = "auth.client.extended-jwt" + ClientRender = "auth.client.render" + ClientSession = "auth.client.session" + ClientForm = "auth.client.form" + ClientProxy = "auth.client.proxy" + ClientSAML = "auth.client.saml" + ClientPasswordless = "auth.client.passwordless" ) const ( diff --git a/pkg/services/authn/authnimpl/registration.go b/pkg/services/authn/authnimpl/registration.go index 562f638565c..ea47102809c 100644 --- a/pkg/services/authn/authnimpl/registration.go +++ b/pkg/services/authn/authnimpl/registration.go @@ -17,10 +17,12 @@ import ( "github.com/grafana/grafana/pkg/services/ldap/service" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/loginattempt" + "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/rendering" + tempuser "github.com/grafana/grafana/pkg/services/temp_user" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) @@ -38,7 +40,7 @@ func ProvideRegistration( features *featuremgmt.FeatureManager, oauthTokenService oauthtoken.OAuthTokenService, socialService social.Service, cache *remotecache.RemoteCache, ldapService service.LDAP, settingsProviderService setting.Provider, - tracer tracing.Tracer, + tracer tracing.Tracer, tempUserService tempuser.Service, notificationService notifications.Service, ) Registration { logger := log.New("authn.registration") @@ -78,6 +80,11 @@ func ProvideRegistration( } } + if cfg.PasswordlessMagicLinkAuth.Enabled && features.IsEnabledGlobally(featuremgmt.FlagPasswordlessMagicLinkAuthentication) { + passwordless := clients.ProvidePasswordless(cfg, loginAttempts, userService, tempUserService, notificationService, cache) + authnSvc.RegisterClient(passwordless) + } + if cfg.AuthProxy.Enabled && len(proxyClients) > 0 { proxy, err := clients.ProvideProxy(cfg, cache, proxyClients...) if err != nil { diff --git a/pkg/services/authn/clients/passwordless.go b/pkg/services/authn/clients/passwordless.go new file mode 100644 index 00000000000..701d7818789 --- /dev/null +++ b/pkg/services/authn/clients/passwordless.go @@ -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 +} diff --git a/pkg/services/authn/clients/passwordless_test.go b/pkg/services/authn/clients/passwordless_test.go new file mode 100644 index 00000000000..0944d1b8a7b --- /dev/null +++ b/pkg/services/authn/clients/passwordless_test.go @@ -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) + }) + } +} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 2e702129e36..e407e1fe82c 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1602,6 +1602,15 @@ var ( Owner: grafanaAppPlatformSquad, RequiresRestart: true, }, + { + Name: "passwordlessMagicLinkAuthentication", + Description: "Enable passwordless login via magic link authentication", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + HideFromAdminPage: true, + AllowSelfServe: false, + }, { Name: "exploreMetricsRelatedLogs", Description: "Display Related Logs in Explore Metrics", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index b257b204893..47a8ab42820 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -213,6 +213,7 @@ azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false preinstallAutoUpdate,GA,@grafana/plugins-platform-backend,false,false,false dashboardSchemaV2,experimental,@grafana/dashboards-squad,false,false,true playlistsWatcher,experimental,@grafana/grafana-app-platform-squad,false,true,false +passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false zipkinBackendMigration,experimental,@grafana/oss-big-tent,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 1656aa935c3..b3b73494085 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -863,6 +863,10 @@ const ( // Enables experimental watcher for playlists FlagPlaylistsWatcher = "playlistsWatcher" + // FlagPasswordlessMagicLinkAuthentication + // Enable passwordless login via magic link authentication + FlagPasswordlessMagicLinkAuthentication = "passwordlessMagicLinkAuthentication" + // FlagExploreMetricsRelatedLogs // Display Related Logs in Explore Metrics FlagExploreMetricsRelatedLogs = "exploreMetricsRelatedLogs" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 4c6d1fa20b8..75eaf898b45 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2462,6 +2462,20 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "passwordlessMagicLinkAuthentication", + "resourceVersion": "1730232874003", + "creationTimestamp": "2024-10-29T20:14:34Z" + }, + "spec": { + "description": "Enable passwordless login via magic link authentication", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "pdfTables", diff --git a/pkg/services/login/authinfo.go b/pkg/services/login/authinfo.go index 103372c942d..b13bf360eb5 100644 --- a/pkg/services/login/authinfo.go +++ b/pkg/services/login/authinfo.go @@ -25,14 +25,15 @@ type Store interface { const ( // modules - PasswordAuthModule = "password" - APIKeyAuthModule = "apikey" - SAMLAuthModule = "auth.saml" - LDAPAuthModule = "ldap" - AuthProxyAuthModule = "authproxy" - JWTModule = "jwt" - ExtendedJWTModule = "extendedjwt" - RenderModule = "render" + PasswordAuthModule = "password" + PasswordlessAuthModule = "passwordless" + APIKeyAuthModule = "apikey" + SAMLAuthModule = "auth.saml" + LDAPAuthModule = "ldap" + AuthProxyAuthModule = "authproxy" + JWTModule = "jwt" + ExtendedJWTModule = "extendedjwt" + RenderModule = "render" // OAuth provider modules AzureADAuthModule = "oauth_azuread" GoogleAuthModule = "oauth_google" diff --git a/pkg/services/temp_user/tempusertest/fake.go b/pkg/services/temp_user/tempusertest/fake.go index 0bd9b803a88..8376b691e83 100644 --- a/pkg/services/temp_user/tempusertest/fake.go +++ b/pkg/services/temp_user/tempusertest/fake.go @@ -11,6 +11,7 @@ var _ tempuser.Service = (*FakeTempUserService)(nil) type FakeTempUserService struct { tempuser.Service GetTempUserByCodeFN func(ctx context.Context, query *tempuser.GetTempUserByCodeQuery) (*tempuser.TempUserDTO, error) + GetTempUsersQueryFN func(ctx context.Context, query *tempuser.GetTempUsersQuery) ([]*tempuser.TempUserDTO, error) UpdateTempUserStatusFN func(ctx context.Context, cmd *tempuser.UpdateTempUserStatusCommand) error CreateTempUserFN func(ctx context.Context, cmd *tempuser.CreateTempUserCommand) (*tempuser.TempUser, error) ExpirePreviousVerificationsFN func(ctx context.Context, cmd *tempuser.ExpirePreviousVerificationsCommand) error @@ -24,6 +25,13 @@ func (f *FakeTempUserService) GetTempUserByCode(ctx context.Context, query *temp return nil, nil } +func (f *FakeTempUserService) GetTempUsersQuery(ctx context.Context, query *tempuser.GetTempUsersQuery) ([]*tempuser.TempUserDTO, error) { + if f.GetTempUsersQueryFN != nil { + return f.GetTempUsersQueryFN(ctx, query) + } + return nil, nil +} + func (f *FakeTempUserService) UpdateTempUserStatus(ctx context.Context, cmd *tempuser.UpdateTempUserStatusCommand) error { if f.UpdateTempUserStatusFN != nil { return f.UpdateTempUserStatusFN(ctx, cmd) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index e0a54e7ab6f..9715d99a427 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -271,6 +271,8 @@ type Cfg struct { JWTAuth AuthJWTSettings ExtJWTAuth ExtJWTSettings + PasswordlessMagicLinkAuth AuthPasswordlessMagicLinkSettings + // SSO Settings Auth SSOSettingsReloadInterval time.Duration SSOSettingsConfigurableProviders map[string]bool @@ -1248,6 +1250,7 @@ func (cfg *Cfg) parseINIFile(iniFile *ini.File) error { cfg.readAuthExtJWTSettings() cfg.readAuthProxySettings() cfg.readSessionConfig() + cfg.readPasswordlessMagicLinkSettings() if err := cfg.readSmtpSettings(); err != nil { return err } diff --git a/pkg/setting/setting_passwordless_magic_link.go b/pkg/setting/setting_passwordless_magic_link.go new file mode 100644 index 00000000000..0f0d6b65d34 --- /dev/null +++ b/pkg/setting/setting_passwordless_magic_link.go @@ -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 +} diff --git a/public/app/core/components/ForgottenPassword/ChangePasswordPage.test.tsx b/public/app/core/components/ForgottenPassword/ChangePasswordPage.test.tsx index 0cd67650137..401843d80c3 100644 --- a/public/app/core/components/ForgottenPassword/ChangePasswordPage.test.tsx +++ b/public/app/core/components/ForgottenPassword/ChangePasswordPage.test.tsx @@ -10,6 +10,9 @@ jest.mock('@grafana/runtime', () => ({ getBackendSrv: () => ({ post: postMock, }), + locationService: { + getSearch: () => new URLSearchParams(), + }, config: { ...jest.requireActual('@grafana/runtime').config, loginError: false, diff --git a/public/app/core/components/Login/LoginCtrl.tsx b/public/app/core/components/Login/LoginCtrl.tsx index 1a8a1abfd5b..7487452b774 100644 --- a/public/app/core/components/Login/LoginCtrl.tsx +++ b/public/app/core/components/Login/LoginCtrl.tsx @@ -1,21 +1,37 @@ import { PureComponent } from 'react'; -import { FetchError, getBackendSrv, isFetchError } from '@grafana/runtime'; +import { FetchError, getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; import config from 'app/core/config'; import { t } from 'app/core/internationalization'; -import { LoginDTO } from './types'; +import { LoginDTO, AuthNRedirectDTO } from './types'; const isOauthEnabled = () => { return !!config.oauth && Object.keys(config.oauth).length > 0; }; +const showPasswordlessConfirmation = () => { + const queryValues = locationService.getSearch(); + return !!queryValues.get('code'); +}; + export interface FormModel { user: string; password: string; email: string; } +export interface PasswordlessFormModel { + email: string; +} + +export interface PasswordlessConfirmationFormModel { + code: string; + confirmationCode: string; + username?: string; + name?: string; +} + interface Props { resetCode?: string; @@ -25,6 +41,9 @@ interface Props { isChangingPassword: boolean; skipPasswordChange: Function; login: (data: FormModel) => void; + passwordlessStart: (data: PasswordlessFormModel) => void; + passwordlessConfirm: (data: PasswordlessConfirmationFormModel) => void; + showPasswordlessConfirmation: boolean; disableLoginForm: boolean; disableUserSignUp: boolean; isOauthEnabled: boolean; @@ -111,6 +130,49 @@ export class LoginCtrl extends PureComponent { }); }; + passwordlessStart = (formModel: PasswordlessFormModel) => { + this.setState({ + loginErrorMessage: undefined, + isLoggingIn: true, + }); + + getBackendSrv() + .post('/api/login/passwordless/start', formModel, { showErrorAlert: false }) + .then((result) => { + window.location.assign(result.URL); + return; + }) + .catch((err) => { + const fetchErrorMessage = isFetchError(err) ? getErrorMessage(err) : undefined; + this.setState({ + isLoggingIn: false, + loginErrorMessage: fetchErrorMessage || t('login.error.unknown', 'Unknown error occurred'), + }); + }); + }; + + passwordlessConfirm = (formModel: PasswordlessConfirmationFormModel) => { + this.setState({ + loginErrorMessage: undefined, + isLoggingIn: true, + }); + + getBackendSrv() + .post('/api/login/passwordless/authenticate', formModel, { showErrorAlert: false }) + .then((result) => { + this.result = result; + this.toGrafana(); + return; + }) + .catch((err) => { + const fetchErrorMessage = isFetchError(err) ? getErrorMessage(err) : undefined; + this.setState({ + isLoggingIn: false, + loginErrorMessage: fetchErrorMessage || t('login.error.unknown', 'Unknown error occurred'), + }); + }); + }; + changeView = (showDefaultPasswordWarning: boolean) => { this.setState({ isChangingPassword: true, @@ -138,7 +200,7 @@ export class LoginCtrl extends PureComponent { render() { const { children } = this.props; const { isLoggingIn, isChangingPassword, showDefaultPasswordWarning, loginErrorMessage } = this.state; - const { login, toGrafana, changePassword } = this; + const { login, toGrafana, changePassword, passwordlessStart, passwordlessConfirm } = this; const { loginHint, passwordHint, disableLoginForm, disableUserSignUp } = config; return ( @@ -150,6 +212,9 @@ export class LoginCtrl extends PureComponent { disableLoginForm, disableUserSignUp, login, + passwordlessStart, + passwordlessConfirm, + showPasswordlessConfirmation: showPasswordlessConfirmation(), isLoggingIn, changePassword, skipPasswordChange: toGrafana, diff --git a/public/app/core/components/Login/LoginPage.tsx b/public/app/core/components/Login/LoginPage.tsx index ef73f53629e..0296e6dd803 100644 --- a/public/app/core/components/Login/LoginPage.tsx +++ b/public/app/core/components/Login/LoginPage.tsx @@ -14,6 +14,8 @@ import LoginCtrl from './LoginCtrl'; import { LoginForm } from './LoginForm'; import { LoginLayout, InnerBox } from './LoginLayout'; import { LoginServiceButtons } from './LoginServiceButtons'; +import { PasswordlessConfirmation } from './PasswordlessConfirmationForm'; +import { PasswordlessLoginForm } from './PasswordlessLoginForm'; import { UserSignup } from './UserSignup'; const LoginPage = () => { @@ -28,6 +30,9 @@ const LoginPage = () => { disableLoginForm, disableUserSignUp, login, + passwordlessStart, + passwordlessConfirm, + showPasswordlessConfirmation, isLoggingIn, changePassword, skipPasswordChange, @@ -36,7 +41,7 @@ const LoginPage = () => { loginErrorMessage, }) => ( - {!isChangingPassword && ( + {!isChangingPassword && !showPasswordlessConfirmation && ( {loginErrorMessage && ( @@ -44,7 +49,7 @@ const LoginPage = () => { )} - {!disableLoginForm && ( + {!disableLoginForm && !config.auth.passwordlessEnabled && ( {!config.auth.disableLogin && ( @@ -59,12 +64,24 @@ const LoginPage = () => { )} + {config.auth.passwordlessEnabled && ( + + )} {!disableUserSignUp && } )} - {isChangingPassword && ( + {config.auth.passwordlessEnabled && showPasswordlessConfirmation && ( + + + + )} + + {isChangingPassword && !config.auth.passwordlessEnabled && ( 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({ 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 ( +
+
+ + + + + {signup && ( + <> + + + + + + )} + +
+
+ ); +}; + +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', + }), + }; +}; diff --git a/public/app/core/components/Login/PasswordlessLoginForm.tsx b/public/app/core/components/Login/PasswordlessLoginForm.tsx new file mode 100644 index 00000000000..f1737510f12 --- /dev/null +++ b/public/app/core/components/Login/PasswordlessLoginForm.tsx @@ -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({ mode: 'onChange' }); + + return ( +
+
+ + + + +
+
+ ); +}; + +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', + }), + }; +}; diff --git a/public/app/core/components/Login/types.ts b/public/app/core/components/Login/types.ts index 4ca72bb46ae..4fa504f1201 100644 --- a/public/app/core/components/Login/types.ts +++ b/public/app/core/components/Login/types.ts @@ -2,3 +2,7 @@ export interface LoginDTO { message: string; redirectUrl: string; } + +export interface AuthNRedirectDTO { + URL: string; +} diff --git a/public/emails/passwordless_verify_existing_user.html b/public/emails/passwordless_verify_existing_user.html new file mode 100644 index 00000000000..fca20f91c71 --- /dev/null +++ b/public/emails/passwordless_verify_existing_user.html @@ -0,0 +1,273 @@ + + + + + {{ Subject .Subject .TemplateData "Verify your email" }} + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+
+

Please verify your email

+
+
+
Copy and paste the confirmation code in the login form to verify your email address. This confirmation code will expire in {{ .Expire }} minutes.
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
{{ .ConfirmationCode }}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+
Alternatively, you can use the button below to verify your email address.
+
+ + + + + + +
+ Verify your email +
+
+
You can also copy and paste this link into your browser directly:
+
+ +
+
+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
© {{ now | date "2006" }} Grafana Labs. Sent by Grafana v{{ .BuildVersion }}.
+
+
+ +
+
+ +
+ + + diff --git a/public/emails/passwordless_verify_existing_user.txt b/public/emails/passwordless_verify_existing_user.txt new file mode 100644 index 00000000000..18a13b74480 --- /dev/null +++ b/public/emails/passwordless_verify_existing_user.txt @@ -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]] diff --git a/public/emails/passwordless_verify_new_user.html b/public/emails/passwordless_verify_new_user.html new file mode 100644 index 00000000000..d6c04cfa58b --- /dev/null +++ b/public/emails/passwordless_verify_new_user.html @@ -0,0 +1,273 @@ + + + + + {{ Subject .Subject .TemplateData "Welcome to Grafana, please complete your sign up!" }} + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+
+

Please complete your signup

+
+
+
Copy and paste the confirmation code in the sign up form to verify your email address. This confirmation code will expire in {{ .Expire }} minutes.
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
{{ .ConfirmationCode }}
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+
Alternatively, you can use the button below to complete your sign up.
+
+ + + + + + +
+ Complete Sign Up +
+
+
You can also copy and paste this link into your browser directly:
+
+ +
+
+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
© {{ now | date "2006" }} Grafana Labs. Sent by Grafana v{{ .BuildVersion }}.
+
+
+ +
+
+ +
+ + + diff --git a/public/emails/passwordless_verify_new_user.txt b/public/emails/passwordless_verify_new_user.txt new file mode 100644 index 00000000000..9ea5b80c931 --- /dev/null +++ b/public/emails/passwordless_verify_new_user.txt @@ -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]] diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 76146131d30..2d0005e46dc 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1459,6 +1459,14 @@ }, "forgot-password": "Forgot your password?", "form": { + "confirmation-code": "Confirmation code is required", + "confirmation-code-label": "Confirmation code", + "confirmation-code-placeholder": "confirmation code", + "email-label": "Email", + "email-placeholder": "email", + "email-required": "Email is required", + "name-label": "Name", + "name-placeholder": "name", "password-label": "Password", "password-placeholder": "password", "password-required": "Password is required", @@ -1466,7 +1474,9 @@ "submit-loading-label": "Logging in...", "username-label": "Email or username", "username-placeholder": "email or username", - "username-required": "Email or username is required" + "username-required": "Email or username is required", + "verify-email-label": "Send a verification email", + "verify-email-loading-label": "Sending email..." }, "services": { "sing-in-with-prefix": "Sign in with {{serviceName}}" diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 46da0d46687..4be0ea99904 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1459,6 +1459,14 @@ }, "forgot-password": "Főřģőŧ yőūř päşşŵőřđ?", "form": { + "confirmation-code": "Cőʼnƒįřmäŧįőʼn čőđę įş řęqūįřęđ", + "confirmation-code-label": "Cőʼnƒįřmäŧįőʼn čőđę", + "confirmation-code-placeholder": "čőʼnƒįřmäŧįőʼn čőđę", + "email-label": "Ēmäįľ", + "email-placeholder": "ęmäįľ", + "email-required": "Ēmäįľ įş řęqūįřęđ", + "name-label": "Ńämę", + "name-placeholder": "ʼnämę", "password-label": "Päşşŵőřđ", "password-placeholder": "päşşŵőřđ", "password-required": "Päşşŵőřđ įş řęqūįřęđ", @@ -1466,7 +1474,9 @@ "submit-loading-label": "Ŀőģģįʼnģ įʼn...", "username-label": "Ēmäįľ őř ūşęřʼnämę", "username-placeholder": "ęmäįľ őř ūşęřʼnämę", - "username-required": "Ēmäįľ őř ūşęřʼnämę įş řęqūįřęđ" + "username-required": "Ēmäįľ őř ūşęřʼnämę įş řęqūįřęđ", + "verify-email-label": "Ŝęʼnđ ä vęřįƒįčäŧįőʼn ęmäįľ", + "verify-email-loading-label": "Ŝęʼnđįʼnģ ęmäįľ..." }, "services": { "sing-in-with-prefix": "Ŝįģʼn įʼn ŵįŧĥ {{serviceName}}"