mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Auth: Passwordless Login Option Using Magic Links (#95436)
* initial passwordless client * passwordless login page * Working basic e2e flow * Add todo comments * Improve the passwordless login flow * improved passwordless login, backend for passwordless signup * add expiration to emails * update email templates & render username & name fields on signup * improve email templates * change login page text while awaiting passwordless code * fix merge conflicts * use claims.TypeUser * add initial passwordless tests * better error messages * simplified error name * remove completed TODOs * linting & minor test improvements & rename passwordless routes * more linting fixes * move code generation to its own func, use locationService to get query params * fix ampersand in email templates & use passwordless api routes in LoginCtrl * txt emails more closely match html email copy * move passwordless auth behind experimental feature toggle * fix PasswordlessLogin property failing typecheck * make update-workspace * user correct placeholder * Update emails/templates/passwordless_verify_existing_user.txt Co-authored-by: Dan Cech <dcech@grafana.com> * Update emails/templates/passwordless_verify_existing_user.mjml Co-authored-by: Dan Cech <dcech@grafana.com> * Update emails/templates/passwordless_verify_new_user.txt Co-authored-by: Dan Cech <dcech@grafana.com> * Update emails/templates/passwordless_verify_new_user.txt Co-authored-by: Dan Cech <dcech@grafana.com> * Update emails/templates/passwordless_verify_new_user.mjml Co-authored-by: Dan Cech <dcech@grafana.com> * use & in email templates * Update emails/templates/passwordless_verify_existing_user.txt Co-authored-by: Dan Cech <dcech@grafana.com> * remove IP address validation * struct for passwordless settings * revert go.work.sum changes * mock locationService.getSearch in failing test --------- Co-authored-by: Mihaly Gyongyosi <mgyongyosi@users.noreply.github.com> Co-authored-by: Dan Cech <dcech@grafana.com>
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
335
pkg/services/authn/clients/passwordless.go
Normal file
335
pkg/services/authn/clients/passwordless.go
Normal 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
|
||||
}
|
||||
160
pkg/services/authn/clients/passwordless_test.go
Normal file
160
pkg/services/authn/clients/passwordless_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user