* 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>
161 lines
5.1 KiB
Go
161 lines
5.1 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|