Skip to content

Commit

Permalink
Auth: Passwordless Login Option Using Magic Links (grafana#95436)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Update emails/templates/passwordless_verify_existing_user.mjml

Co-authored-by: Dan Cech <[email protected]>

* Update emails/templates/passwordless_verify_new_user.txt

Co-authored-by: Dan Cech <[email protected]>

* Update emails/templates/passwordless_verify_new_user.txt

Co-authored-by: Dan Cech <[email protected]>

* Update emails/templates/passwordless_verify_new_user.mjml

Co-authored-by: Dan Cech <[email protected]>

* use &amp; in email templates

* Update emails/templates/passwordless_verify_existing_user.txt

Co-authored-by: Dan Cech <[email protected]>

* remove IP address validation

* struct for passwordless settings

* revert go.work.sum changes

* mock locationService.getSearch in failing test

---------

Co-authored-by: Mihaly Gyongyosi <[email protected]>
Co-authored-by: Dan Cech <[email protected]>
  • Loading branch information
3 people authored Nov 14, 2024
1 parent c865958 commit 6abe99e
Show file tree
Hide file tree
Showing 36 changed files with 1,644 additions and 27 deletions.
5 changes: 5 additions & 0 deletions conf/defaults.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions emails/templates/passwordless_verify_existing_user.mjml
Original file line number Diff line number Diff line change
@@ -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 }}&amp;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 }}&amp;confirmationCode={{ .ConfirmationCode }}"
>{{ .AppUrl }}login?code={{ .Code }}&amp;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>
10 changes: 10 additions & 0 deletions emails/templates/passwordless_verify_existing_user.txt
Original file line number Diff line number Diff line change
@@ -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]]
53 changes: 53 additions & 0 deletions emails/templates/passwordless_verify_new_user.mjml
Original file line number Diff line number Diff line change
@@ -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 }}&amp;confirmationCode={{ .ConfirmationCode }}&amp;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 }}&amp;confirmationCode={{ .ConfirmationCode }}&amp;signup=true"
>{{ .AppUrl }}login?code={{ .Code }}&amp;confirmationCode={{ .ConfirmationCode }}&amp;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>
10 changes: 10 additions & 0 deletions emails/templates/passwordless_verify_new_user.txt
Original file line number Diff line number Diff line change
@@ -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]]
1 change: 1 addition & 0 deletions packages/grafana-data/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,5 +278,6 @@ export interface AuthSettings {
GenericOAuthSkipOrgRoleSync?: boolean;

disableLogin?: boolean;
passwordlessEnabled?: boolean;
basicAuthStrongPasswordPolicy?: boolean;
}
1 change: 1 addition & 0 deletions packages/grafana-data/src/types/featureToggles.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export interface FeatureToggles {
preinstallAutoUpdate?: boolean;
dashboardSchemaV2?: boolean;
playlistsWatcher?: boolean;
passwordlessMagicLinkAuthentication?: boolean;
exploreMetricsRelatedLogs?: boolean;
enableExtensionsAdminPage?: boolean;
zipkinBackendMigration?: boolean;
Expand Down
13 changes: 13 additions & 0 deletions packages/grafana-e2e-selectors/src/selectors/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]: '/',
Expand Down
6 changes: 6 additions & 0 deletions pkg/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions pkg/api/dtos/frontend_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type FrontendSettingsAuthDTO struct {

DisableLogin bool `json:"disableLogin"`
BasicAuthStrongPasswordPolicy bool `json:"basicAuthStrongPasswordPolicy"`
PasswordlessEnabled bool `json:"passwordlessEnabled"`
}

type FrontendSettingsBuildInfoDTO struct {
Expand Down Expand Up @@ -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"`
Expand Down
1 change: 1 addition & 0 deletions pkg/api/frontendsettings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
20 changes: 20 additions & 0 deletions pkg/api/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
21 changes: 11 additions & 10 deletions pkg/services/authn/authn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
9 changes: 8 additions & 1 deletion pkg/services/authn/authnimpl/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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")

Expand Down Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit 6abe99e

Please sign in to comment.