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 &amp; 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>
This commit is contained in:
colin-stuart
2024-11-14 08:50:55 -05:00
committed by GitHub
parent c865958292
commit 6abe99efd6
36 changed files with 1644 additions and 27 deletions

View File

@@ -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))

View File

@@ -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"`

View File

@@ -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() {

View File

@@ -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")

View File

@@ -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 (

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
})
}
}

View File

@@ -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",

View File

@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
213 preinstallAutoUpdate GA @grafana/plugins-platform-backend false false false
214 dashboardSchemaV2 experimental @grafana/dashboards-squad false false true
215 playlistsWatcher experimental @grafana/grafana-app-platform-squad false true false
216 passwordlessMagicLinkAuthentication experimental @grafana/identity-access-team false false false
217 exploreMetricsRelatedLogs experimental @grafana/observability-metrics false false true
218 enableExtensionsAdminPage experimental @grafana/plugins-platform-backend false true false
219 zipkinBackendMigration experimental @grafana/oss-big-tent false false false

View File

@@ -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"

View File

@@ -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",

View File

@@ -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"

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}