feat(api): add password reset and change to user service (#6036)

* feat(api): add password reset and change to user service

* integration tests

* invalidate password check after password change

* handle notification type

* fix proto
This commit is contained in:
Livio Spring
2023-06-20 17:34:06 +02:00
committed by GitHub
parent 1017568cf1
commit 82e7333169
20 changed files with 1373 additions and 54 deletions

View File

@@ -0,0 +1,71 @@
package user
import (
"context"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetRequest) (_ *user.PasswordResetResponse, err error) {
var details *domain.ObjectDetails
var code *string
switch m := req.GetMedium().(type) {
case *user.PasswordResetRequest_SendLink:
details, code, err = s.command.RequestPasswordResetURLTemplate(ctx, req.GetUserId(), m.SendLink.GetUrlTemplate(), notificationTypeToDomain(m.SendLink.GetNotificationType()))
case *user.PasswordResetRequest_ReturnCode:
details, code, err = s.command.RequestPasswordResetReturnCode(ctx, req.GetUserId())
case nil:
details, code, err = s.command.RequestPasswordReset(ctx, req.GetUserId())
default:
err = caos_errs.ThrowUnimplementedf(nil, "USERv2-SDeeg", "verification oneOf %T in method RequestPasswordReset not implemented", m)
}
if err != nil {
return nil, err
}
return &user.PasswordResetResponse{
Details: object.DomainToDetailsPb(details),
VerificationCode: code,
}, nil
}
func notificationTypeToDomain(notificationType user.NotificationType) domain.NotificationType {
switch notificationType {
case user.NotificationType_NOTIFICATION_TYPE_Email:
return domain.NotificationTypeEmail
case user.NotificationType_NOTIFICATION_TYPE_SMS:
return domain.NotificationTypeSms
case user.NotificationType_NOTIFICATION_TYPE_Unspecified:
return domain.NotificationTypeEmail
default:
return domain.NotificationTypeEmail
}
}
func (s *Server) SetPassword(ctx context.Context, req *user.SetPasswordRequest) (_ *user.SetPasswordResponse, err error) {
var resourceOwner = authz.GetCtxData(ctx).ResourceOwner
var details *domain.ObjectDetails
switch v := req.GetVerification().(type) {
case *user.SetPasswordRequest_CurrentPassword:
details, err = s.command.ChangePassword(ctx, resourceOwner, req.GetUserId(), v.CurrentPassword, req.GetNewPassword().GetPassword(), "")
case *user.SetPasswordRequest_VerificationCode:
details, err = s.command.SetPasswordWithVerifyCode(ctx, resourceOwner, req.GetUserId(), v.VerificationCode, req.GetNewPassword().GetPassword(), "")
case nil:
details, err = s.command.SetPassword(ctx, resourceOwner, req.GetUserId(), req.GetNewPassword().GetPassword(), req.GetNewPassword().GetChangeRequired())
default:
err = caos_errs.ThrowUnimplementedf(nil, "USERv2-SFdf2", "verification oneOf %T in method SetPasswordRequest not implemented", v)
}
if err != nil {
return nil, err
}
return &user.SetPasswordResponse{
Details: object.DomainToDetailsPb(details),
}, nil
}

View File

@@ -0,0 +1,232 @@
//go:build integration
package user_test
import (
"context"
"testing"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/integration"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func TestServer_RequestPasswordReset(t *testing.T) {
userID := Tester.CreateHumanUser(CTX).GetUserId()
tests := []struct {
name string
req *user.PasswordResetRequest
want *user.PasswordResetResponse
wantErr bool
}{
{
name: "default medium",
req: &user.PasswordResetRequest{
UserId: userID,
},
want: &user.PasswordResetResponse{
Details: &object.Details{
Sequence: 1,
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "custom url template",
req: &user.PasswordResetRequest{
UserId: userID,
Medium: &user.PasswordResetRequest_SendLink{
SendLink: &user.SendPasswordResetLink{
NotificationType: user.NotificationType_NOTIFICATION_TYPE_Email,
UrlTemplate: gu.Ptr("https://example.com/password/change?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"),
},
},
},
want: &user.PasswordResetResponse{
Details: &object.Details{
Sequence: 1,
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "template error",
req: &user.PasswordResetRequest{
UserId: userID,
Medium: &user.PasswordResetRequest_SendLink{
SendLink: &user.SendPasswordResetLink{
UrlTemplate: gu.Ptr("{{"),
},
},
},
wantErr: true,
},
{
name: "return code",
req: &user.PasswordResetRequest{
UserId: userID,
Medium: &user.PasswordResetRequest_ReturnCode{
ReturnCode: &user.ReturnPasswordResetCode{},
},
},
want: &user.PasswordResetResponse{
Details: &object.Details{
Sequence: 1,
ChangeDate: timestamppb.Now(),
ResourceOwner: Tester.Organisation.ID,
},
VerificationCode: gu.Ptr("xxx"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.PasswordReset(CTX, tt.req)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
integration.AssertDetails(t, tt.want, got)
if tt.want.GetVerificationCode() != "" {
assert.NotEmpty(t, got.GetVerificationCode())
}
})
}
}
func TestServer_SetPassword(t *testing.T) {
type args struct {
ctx context.Context
req *user.SetPasswordRequest
}
tests := []struct {
name string
prepare func(request *user.SetPasswordRequest) error
args args
want *user.SetPasswordResponse
wantErr bool
}{
{
name: "missing user id",
prepare: func(request *user.SetPasswordRequest) error {
return nil
},
args: args{
ctx: CTX,
req: &user.SetPasswordRequest{},
},
wantErr: true,
},
{
name: "set successful",
prepare: func(request *user.SetPasswordRequest) error {
userID := Tester.CreateHumanUser(CTX).GetUserId()
request.UserId = userID
return nil
},
args: args{
ctx: CTX,
req: &user.SetPasswordRequest{
NewPassword: &user.Password{
Password: "Secr3tP4ssw0rd!",
},
},
},
want: &user.SetPasswordResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "change successful",
prepare: func(request *user.SetPasswordRequest) error {
userID := Tester.CreateHumanUser(CTX).GetUserId()
request.UserId = userID
_, err := Client.SetPassword(CTX, &user.SetPasswordRequest{
UserId: userID,
NewPassword: &user.Password{
Password: "InitialPassw0rd!",
},
})
if err != nil {
return err
}
return nil
},
args: args{
ctx: CTX,
req: &user.SetPasswordRequest{
NewPassword: &user.Password{
Password: "Secr3tP4ssw0rd!",
},
Verification: &user.SetPasswordRequest_CurrentPassword{
CurrentPassword: "InitialPassw0rd!",
},
},
},
want: &user.SetPasswordResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "set with code successful",
prepare: func(request *user.SetPasswordRequest) error {
userID := Tester.CreateHumanUser(CTX).GetUserId()
request.UserId = userID
resp, err := Client.PasswordReset(CTX, &user.PasswordResetRequest{
UserId: userID,
Medium: &user.PasswordResetRequest_ReturnCode{
ReturnCode: &user.ReturnPasswordResetCode{},
},
})
if err != nil {
return err
}
request.Verification = &user.SetPasswordRequest_VerificationCode{
VerificationCode: resp.GetVerificationCode(),
}
return nil
},
args: args{
ctx: CTX,
req: &user.SetPasswordRequest{
NewPassword: &user.Password{
Password: "Secr3tP4ssw0rd!",
},
},
},
want: &user.SetPasswordResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.prepare(tt.args.req)
require.NoError(t, err)
got, err := Client.SetPassword(tt.args.ctx, tt.args.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, got)
integration.AssertDetails(t, tt.want, got)
})
}
}

View File

@@ -0,0 +1,39 @@
package user
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/zitadel/zitadel/internal/domain"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func Test_notificationTypeToDomain(t *testing.T) {
tests := []struct {
name string
notificationType user.NotificationType
want domain.NotificationType
}{
{
"unspecified",
user.NotificationType_NOTIFICATION_TYPE_Unspecified,
domain.NotificationTypeEmail,
},
{
"email",
user.NotificationType_NOTIFICATION_TYPE_Email,
domain.NotificationTypeEmail,
},
{
"sms",
user.NotificationType_NOTIFICATION_TYPE_SMS,
domain.NotificationTypeSms,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, notificationTypeToDomain(tt.notificationType), "notificationTypeToDomain(%v)", tt.notificationType)
})
}
}

View File

@@ -60,10 +60,10 @@ func (l *Login) handleInitPasswordCheck(w http.ResponseWriter, r *http.Request)
l.resendPasswordSet(w, r, authReq)
return
}
l.checkPWCode(w, r, authReq, data, nil)
l.checkPWCode(w, r, authReq, data)
}
func (l *Login) checkPWCode(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *initPasswordFormData, err error) {
func (l *Login) checkPWCode(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *initPasswordFormData) {
if data.Password != data.PasswordConfirm {
err := errors.ThrowInvalidArgument(nil, "VIEW-KaGue", "Errors.User.Password.ConfirmationWrong")
l.renderInitPassword(w, r, authReq, data.UserID, data.Code, err)
@@ -74,12 +74,7 @@ func (l *Login) checkPWCode(w http.ResponseWriter, r *http.Request, authReq *dom
userOrg = authReq.UserOrgID
}
userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context())
passwordCodeGenerator, err := l.query.InitEncryptionGenerator(r.Context(), domain.SecretGeneratorTypePasswordResetCode, l.userCodeAlg)
if err != nil {
l.renderInitPassword(w, r, authReq, data.UserID, "", err)
return
}
err = l.command.SetPasswordWithVerifyCode(setContext(r.Context(), userOrg), userOrg, data.UserID, data.Code, data.Password, userAgentID, passwordCodeGenerator)
_, err := l.command.SetPasswordWithVerifyCode(setContext(r.Context(), userOrg), userOrg, data.UserID, data.Code, data.Password, userAgentID)
if err != nil {
l.renderInitPassword(w, r, authReq, data.UserID, "", err)
return

View File

@@ -46,6 +46,12 @@ func eventstoreExpect(t *testing.T, expects ...expect) *eventstore.Eventstore {
return es
}
func expectEventstore(expects ...expect) func(*testing.T) *eventstore.Eventstore {
return func(t *testing.T) *eventstore.Eventstore {
return eventstoreExpect(t, expects...)
}
}
func eventPusherToEvents(eventsPushes ...eventstore.Command) []*repository.Event {
events := make([]*repository.Event, len(eventsPushes))
for i, event := range eventsPushes {

View File

@@ -26,6 +26,9 @@ func (c *Commands) SetPassword(ctx context.Context, orgID, userID, passwordStrin
if !existingPassword.UserState.Exists() {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3M0fs", "Errors.User.NotFound")
}
if err = c.checkPermission(ctx, domain.PermissionUserWrite, existingPassword.ResourceOwner, userID); err != nil {
return nil, err
}
password := &domain.Password{
SecretString: passwordString,
ChangeRequired: oneTime,
@@ -46,28 +49,28 @@ func (c *Commands) SetPassword(ctx context.Context, orgID, userID, passwordStrin
return writeModelToObjectDetails(&existingPassword.WriteModel), nil
}
func (c *Commands) SetPasswordWithVerifyCode(ctx context.Context, orgID, userID, code, passwordString, userAgentID string, passwordVerificationCode crypto.Generator) (err error) {
func (c *Commands) SetPasswordWithVerifyCode(ctx context.Context, orgID, userID, code, passwordString, userAgentID string) (objectDetails *domain.ObjectDetails, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if userID == "" {
return caos_errs.ThrowInvalidArgument(nil, "COMMAND-3M9fs", "Errors.IDMissing")
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-3M9fs", "Errors.IDMissing")
}
if passwordString == "" {
return caos_errs.ThrowInvalidArgument(nil, "COMMAND-Mf0sd", "Errors.User.Password.Empty")
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-Mf0sd", "Errors.User.Password.Empty")
}
existingCode, err := c.passwordWriteModel(ctx, userID, orgID)
if err != nil {
return err
return nil, err
}
if existingCode.Code == nil || existingCode.UserState == domain.UserStateUnspecified || existingCode.UserState == domain.UserStateDeleted {
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-2M9fs", "Errors.User.Code.NotFound")
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-2M9fs", "Errors.User.Code.NotFound")
}
err = crypto.VerifyCode(existingCode.CodeCreationDate, existingCode.CodeExpiry, existingCode.Code, code, passwordVerificationCode)
err = crypto.VerifyCodeWithAlgorithm(existingCode.CodeCreationDate, existingCode.CodeExpiry, existingCode.Code, code, c.userEncryption)
if err != nil {
return err
return nil, err
}
password := &domain.Password{
@@ -77,10 +80,13 @@ func (c *Commands) SetPasswordWithVerifyCode(ctx context.Context, orgID, userID,
userAgg := UserAggregateFromWriteModel(&existingCode.WriteModel)
passwordEvent, err := c.changePassword(ctx, userAgentID, password, userAgg, existingCode)
if err != nil {
return err
return nil, err
}
_, err = c.eventstore.Push(ctx, passwordEvent)
return err
err = c.pushAppendAndReduce(ctx, existingCode, passwordEvent)
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&existingCode.WriteModel), nil
}
func (c *Commands) ChangePassword(ctx context.Context, orgID, userID, oldPassword, newPassword, userAgentID string) (objectDetails *domain.ObjectDetails, err error) {

View File

@@ -2,6 +2,7 @@ package command
import (
"context"
"errors"
"testing"
"time"
@@ -22,6 +23,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
userPasswordAlg crypto.HashAlgorithm
checkPermission domain.PermissionCheck
}
type args struct {
ctx context.Context
@@ -72,6 +74,49 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
err: caos_errs.IsPreconditionFailed,
},
},
{
name: "missing permission, error",
fields: fields{
eventstore: eventstoreExpect(
t,
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"email@test.ch",
true,
),
),
eventFromEventPusher(
user.NewHumanEmailVerifiedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
userID: "user1",
resourceOwner: "org1",
password: "password",
oneTime: true,
},
res: res{
err: func(err error) bool {
return errors.Is(err, caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"))
},
},
},
{
name: "change password onetime, ok",
fields: fields{
@@ -129,6 +174,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
),
),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: context.Background(),
@@ -200,6 +246,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
),
),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
checkPermission: newMockPermissionCheckAllowed(),
},
args: args{
ctx: context.Background(),
@@ -220,6 +267,7 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore,
userPasswordAlg: tt.fields.userPasswordAlg,
checkPermission: tt.fields.checkPermission,
}
got, err := r.SetPassword(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.password, tt.args.oneTime)
if tt.res.err == nil {
@@ -235,19 +283,19 @@ func TestCommandSide_SetOneTimePassword(t *testing.T) {
}
}
func TestCommandSide_SetPassword(t *testing.T) {
func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
userEncryption crypto.EncryptionAlgorithm
userPasswordAlg crypto.HashAlgorithm
}
type args struct {
ctx context.Context
userID string
code string
resourceOwner string
password string
agentID string
secretGenerator crypto.Generator
ctx context.Context
userID string
code string
resourceOwner string
password string
agentID string
}
type res struct {
want *domain.ObjectDetails
@@ -377,14 +425,14 @@ func TestCommandSide_SetPassword(t *testing.T) {
),
),
),
userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: context.Background(),
userID: "user1",
code: "test",
resourceOwner: "org1",
password: "password",
secretGenerator: GetMockSecretGenerator(t),
ctx: context.Background(),
userID: "user1",
code: "test",
resourceOwner: "org1",
password: "password",
},
res: res{
err: caos_errs.IsPreconditionFailed,
@@ -460,14 +508,14 @@ func TestCommandSide_SetPassword(t *testing.T) {
),
),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: context.Background(),
userID: "user1",
resourceOwner: "org1",
password: "password",
code: "a",
secretGenerator: GetMockSecretGenerator(t),
ctx: context.Background(),
userID: "user1",
resourceOwner: "org1",
password: "password",
code: "a",
},
res: res{
want: &domain.ObjectDetails{
@@ -481,14 +529,18 @@ func TestCommandSide_SetPassword(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore,
userPasswordAlg: tt.fields.userPasswordAlg,
userEncryption: tt.fields.userEncryption,
}
err := r.SetPasswordWithVerifyCode(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.code, tt.args.password, tt.args.agentID, tt.args.secretGenerator)
got, err := r.SetPasswordWithVerifyCode(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.code, tt.args.password, tt.args.agentID)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assert.Equal(t, tt.res.want, got)
}
})
}
}

View File

@@ -15,7 +15,7 @@ import (
)
// RegisterUserPasskey creates a passkey registration for the current authenticated user.
// UserID, ussualy taken from the request is compaired against the user ID in the context.
// UserID, usually taken from the request is compared against the user ID in the context.
func (c *Commands) RegisterUserPasskey(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment) (*domain.WebAuthNRegistrationDetails, error) {
if err := authz.UserIDInCTX(ctx, userID); err != nil {
return nil, err
@@ -40,7 +40,7 @@ type eventCallback func(context.Context, *eventstore.Aggregate) eventstore.Comma
// A code can only be used once.
// Upon success an event callback is returned, which must be called after
// all other events for the current request are created.
// This prevent consuming a code when another error occurred after verification.
// This prevents consuming a code when another error occurred after verification.
func (c *Commands) verifyUserPasskeyCode(ctx context.Context, userID, resourceOwner, codeID, code string, alg crypto.EncryptionAlgorithm) (eventCallback, error) {
wm := NewHumanPasswordlessInitCodeWriteModel(userID, codeID, resourceOwner)
err := c.eventstore.FilterToQueryReducer(ctx, wm)
@@ -122,7 +122,7 @@ func (c *Commands) AddUserPasskeyCodeURLTemplate(ctx context.Context, userID, re
}
// AddUserPasskeyCodeReturn generates and returns a Passkey code.
// No email will be send to the user.
// No email will be sent to the user.
func (c *Commands) AddUserPasskeyCodeReturn(ctx context.Context, userID, resourceOwner string, alg crypto.EncryptionAlgorithm) (*domain.PasskeyCodeDetails, error) {
return c.addUserPasskeyCode(ctx, userID, resourceOwner, alg, "", true)
}

View File

@@ -0,0 +1,71 @@
package command
import (
"context"
"io"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/repository/user"
)
// RequestPasswordReset generates a code
// and triggers a notification e-mail with the default confirmation URL format.
func (c *Commands) RequestPasswordReset(ctx context.Context, userID string) (*domain.ObjectDetails, *string, error) {
return c.requestPasswordReset(ctx, userID, false, "", domain.NotificationTypeEmail)
}
// RequestPasswordResetURLTemplate generates a code
// and triggers a notification e-mail with the confirmation URL rendered from the passed urlTmpl.
// urlTmpl must be a valid [tmpl.Template].
func (c *Commands) RequestPasswordResetURLTemplate(ctx context.Context, userID, urlTmpl string, notificationType domain.NotificationType) (*domain.ObjectDetails, *string, error) {
if err := domain.RenderConfirmURLTemplate(io.Discard, urlTmpl, userID, "code", "orgID"); err != nil {
return nil, nil, err
}
return c.requestPasswordReset(ctx, userID, false, urlTmpl, notificationType)
}
// RequestPasswordResetReturnCode generates a code and does not send a notification email.
// The generated plain text code will be returned.
func (c *Commands) RequestPasswordResetReturnCode(ctx context.Context, userID string) (*domain.ObjectDetails, *string, error) {
return c.requestPasswordReset(ctx, userID, true, "", 0)
}
// requestPasswordReset creates a code for a password change.
// returnCode controls if the plain text version of the code will be set in the return object.
// When the plain text code is returned, no notification e-mail will be sent to the user.
// urlTmpl allows changing the target URL that is used by the e-mail and should be a validated Go template, if used.
func (c *Commands) requestPasswordReset(ctx context.Context, userID string, returnCode bool, urlTmpl string, notificationType domain.NotificationType) (_ *domain.ObjectDetails, plainCode *string, err error) {
if userID == "" {
return nil, nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-SAFdda", "Errors.User.IDMissing")
}
model, err := c.getHumanWriteModelByID(ctx, userID, "")
if err != nil {
return nil, nil, err
}
if !model.UserState.Exists() {
return nil, nil, caos_errs.ThrowNotFound(nil, "COMMAND-SAF4f", "Errors.User.NotFound")
}
if model.UserState == domain.UserStateInitial {
return nil, nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised")
}
if authz.GetCtxData(ctx).UserID != userID {
if err = c.checkPermission(ctx, domain.PermissionUserWrite, model.ResourceOwner, userID); err != nil {
return nil, nil, err
}
}
code, err := c.newCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypePasswordResetCode, c.userEncryption)
if err != nil {
return nil, nil, err
}
cmd := user.NewHumanPasswordCodeAddedEventV2(ctx, UserAggregateFromWriteModel(&model.WriteModel), code.Crypted, code.Expiry, notificationType, urlTmpl, returnCode)
if returnCode {
plainCode = &code.Plain
}
if err = c.pushAppendAndReduce(ctx, model, cmd); err != nil {
return nil, nil, err
}
return writeModelToObjectDetails(&model.WriteModel), plainCode, nil
}

View File

@@ -0,0 +1,611 @@
package command
import (
"context"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user"
)
func TestCommands_RequestPasswordReset(t *testing.T) {
type fields struct {
checkPermission domain.PermissionCheck
eventstore func(t *testing.T) *eventstore.Eventstore
userEncryption crypto.EncryptionAlgorithm
}
type args struct {
ctx context.Context
userID string
}
tests := []struct {
name string
fields fields
args args
wantErr error
}{
{
name: "missing userID",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
userID: "",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "COMMAND-SAFdda", "Errors.User.IDMissing"),
},
{
name: "user not existing",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowNotFound(nil, "COMMAND-SAF4f", "Errors.User.NotFound"),
},
{
name: "user not initialized",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
eventFromEventPusher(
user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised"),
},
{
name: "missing permission",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: tt.fields.checkPermission,
eventstore: tt.fields.eventstore(t),
userEncryption: tt.fields.userEncryption,
}
_, _, err := c.RequestPasswordReset(tt.args.ctx, tt.args.userID)
require.ErrorIs(t, err, tt.wantErr)
// successful cases are tested in TestCommands_requestPasswordReset
})
}
}
func TestCommands_RequestPasswordResetReturnCode(t *testing.T) {
type fields struct {
checkPermission domain.PermissionCheck
eventstore func(t *testing.T) *eventstore.Eventstore
userEncryption crypto.EncryptionAlgorithm
}
type args struct {
ctx context.Context
userID string
}
tests := []struct {
name string
fields fields
args args
wantErr error
}{
{
name: "missing userID",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
userID: "",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "COMMAND-SAFdda", "Errors.User.IDMissing"),
},
{
name: "user not existing",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowNotFound(nil, "COMMAND-SAF4f", "Errors.User.NotFound"),
},
{
name: "user not initialized",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
eventFromEventPusher(
user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised"),
},
{
name: "missing permission",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: tt.fields.checkPermission,
eventstore: tt.fields.eventstore(t),
userEncryption: tt.fields.userEncryption,
}
_, _, err := c.RequestPasswordResetReturnCode(tt.args.ctx, tt.args.userID)
require.ErrorIs(t, err, tt.wantErr)
// successful cases are tested in TestCommands_requestPasswordReset
})
}
}
func TestCommands_RequestPasswordResetURLTemplate(t *testing.T) {
type fields struct {
checkPermission domain.PermissionCheck
eventstore func(t *testing.T) *eventstore.Eventstore
userEncryption crypto.EncryptionAlgorithm
}
type args struct {
ctx context.Context
userID string
urlTmpl string
notificationType domain.NotificationType
}
tests := []struct {
name string
fields fields
args args
wantErr error
}{
{
name: "invalid template",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
userID: "user1",
urlTmpl: "{{",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
},
{
name: "missing userID",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
userID: "",
},
wantErr: caos_errs.ThrowInvalidArgument(nil, "COMMAND-SAFdda", "Errors.User.IDMissing"),
},
{
name: "user not existing",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowNotFound(nil, "COMMAND-SAF4f", "Errors.User.NotFound"),
},
{
name: "user not initialized",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
eventFromEventPusher(
user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised"),
},
{
name: "missing permission",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: tt.fields.checkPermission,
eventstore: tt.fields.eventstore(t),
userEncryption: tt.fields.userEncryption,
}
_, _, err := c.RequestPasswordResetURLTemplate(tt.args.ctx, tt.args.userID, tt.args.urlTmpl, tt.args.notificationType)
require.ErrorIs(t, err, tt.wantErr)
// successful cases are tested in TestCommands_requestPasswordReset
})
}
}
func TestCommands_requestPasswordReset(t *testing.T) {
type fields struct {
checkPermission domain.PermissionCheck
eventstore func(t *testing.T) *eventstore.Eventstore
userEncryption crypto.EncryptionAlgorithm
newCode cryptoCodeFunc
}
type args struct {
ctx context.Context
userID string
returnCode bool
urlTmpl string
notificationType domain.NotificationType
}
type res struct {
details *domain.ObjectDetails
code *string
err error
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "missing userID",
fields: fields{
eventstore: expectEventstore(),
},
args: args{
ctx: context.Background(),
userID: "",
},
res: res{
err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-SAFdda", "Errors.User.IDMissing"),
},
},
{
name: "user not existing",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
res: res{
err: caos_errs.ThrowNotFound(nil, "COMMAND-SAF4f", "Errors.User.NotFound"),
},
},
{
name: "user not initialized",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
eventFromEventPusher(
user.NewHumanInitialCodeAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{CryptoType: crypto.TypeEncryption, Algorithm: "enc", KeyID: "keyID", Crypted: []byte("code")}, 10*time.Second),
),
),
),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
res: res{
err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfe4g", "Errors.User.NotInitialised"),
},
},
{
name: "missing permission",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
res: res{
err: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
},
{
name: "code generated",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
expectPush(
eventPusherToEvents(
user.NewHumanPasswordCodeAddedEventV2(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
10*time.Minute,
domain.NotificationTypeEmail,
"",
false,
)),
),
),
checkPermission: newMockPermissionCheckAllowed(),
newCode: mockCode("code", 10*time.Minute),
},
args: args{
ctx: context.Background(),
userID: "userID",
},
res: res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
code: nil,
},
},
{
name: "code generated template",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
expectPush(
eventPusherToEvents(
user.NewHumanPasswordCodeAddedEventV2(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
10*time.Minute,
domain.NotificationTypeEmail,
"https://example.com/password/changey?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
false,
)),
),
),
checkPermission: newMockPermissionCheckAllowed(),
newCode: mockCode("code", 10*time.Minute),
},
args: args{
ctx: context.Background(),
userID: "userID",
urlTmpl: "https://example.com/password/changey?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
},
res: res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
code: nil,
},
},
{
name: "code generated template sms",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
expectPush(
eventPusherToEvents(
user.NewHumanPasswordCodeAddedEventV2(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
10*time.Minute,
domain.NotificationTypeSms,
"https://example.com/password/changey?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
false,
)),
),
),
checkPermission: newMockPermissionCheckAllowed(),
newCode: mockCode("code", 10*time.Minute),
},
args: args{
ctx: context.Background(),
userID: "userID",
urlTmpl: "https://example.com/password/changey?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
notificationType: domain.NotificationTypeSms,
},
res: res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
code: nil,
},
},
{
name: "code generated returned",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
"username", "firstname", "lastname", "nickname", "displayname",
language.English, domain.GenderUnspecified, "email", false),
),
),
expectPush(
eventPusherToEvents(
user.NewHumanPasswordCodeAddedEventV2(context.Background(), &user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
10*time.Minute,
domain.NotificationTypeEmail,
"",
true,
)),
),
),
checkPermission: newMockPermissionCheckAllowed(),
newCode: mockCode("code", 10*time.Minute),
},
args: args{
ctx: context.Background(),
userID: "userID",
returnCode: true,
},
res: res{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
},
code: gu.Ptr("code"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: tt.fields.checkPermission,
eventstore: tt.fields.eventstore(t),
userEncryption: tt.fields.userEncryption,
newCode: tt.fields.newCode,
}
got, gotPlainCode, err := c.requestPasswordReset(tt.args.ctx, tt.args.userID, tt.args.returnCode, tt.args.urlTmpl, tt.args.notificationType)
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.details, got)
assert.Equal(t, tt.res.code, gotPlainCode)
})
}
}

View File

@@ -121,10 +121,14 @@ func IsCodeExpired(creationDate time.Time, expiry time.Duration) bool {
}
func VerifyCode(creationDate time.Time, expiry time.Duration, cryptoCode *CryptoValue, verificationCode string, g Generator) error {
return VerifyCodeWithAlgorithm(creationDate, expiry, cryptoCode, verificationCode, g.Alg())
}
func VerifyCodeWithAlgorithm(creationDate time.Time, expiry time.Duration, cryptoCode *CryptoValue, verificationCode string, algorithm Crypto) error {
if IsCodeExpired(creationDate, expiry) {
return errors.ThrowPreconditionFailed(nil, "CODE-QvUQ4P", "Errors.User.Code.Expired")
}
switch alg := g.Alg().(type) {
switch alg := algorithm.(type) {
case EncryptionAlgorithm:
return verifyEncryptedCode(cryptoCode, verificationCode, alg)
case HashAlgorithm:

View File

@@ -71,7 +71,7 @@ func TestVerifyCode(t *testing.T) {
expiry: 5 * time.Minute,
cryptoCode: nil,
verificationCode: "",
g: nil,
g: createMockGenerator(t, createMockCrypto(t)),
},
true,
},

View File

@@ -285,6 +285,16 @@ func NewCopyCol(column, from string) handler.Column {
}
}
func NewLessThanCond(column string, value interface{}) handler.Condition {
return handler.Condition{
Name: column,
Value: value,
ParameterOpt: func(placeholder string) string {
return " < " + placeholder
},
}
}
// NewCopyStatement creates a new upsert statement which updates a column from an existing row
// cols represent the columns which are objective to change.
// if the value of a col is empty the data will be copied from the selected row
@@ -390,6 +400,9 @@ func conditionsToWhere(cols []handler.Condition, paramOffset int) (wheres []stri
for i, col := range cols {
wheres[i] = "(" + col.Name + " = $" + strconv.Itoa(i+1+paramOffset) + ")"
if col.ParameterOpt != nil {
wheres[i] = "(" + col.Name + col.ParameterOpt("$"+strconv.Itoa(i+1+paramOffset)) + ")"
}
values[i] = col.Value
}

View File

@@ -251,6 +251,9 @@ func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-Eeg3s", "reduce.wrong.event.type %s", user.HumanPasswordCodeAddedType)
}
if e.CodeReturned {
return crdb.NewNoOpStatement(e), nil
}
ctx := HandlerContext(event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.UserV1PasswordCodeAddedType, user.UserV1PasswordCodeSentType,
@@ -317,7 +320,7 @@ func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler
u.metricFailedDeliveriesSMS,
)
}
err = notify.SendPasswordCode(notifyUser, origin, code)
err = notify.SendPasswordCode(notifyUser, origin, code, e.URLTemplate)
if err != nil {
return nil, err
}

View File

@@ -1,13 +1,24 @@
package types
import (
"strings"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendPasswordCode(user *query.NotifyUser, origin, code string) error {
url := login.InitPasswordLink(origin, user.ID, code, user.ResourceOwner)
func (notify Notify) SendPasswordCode(user *query.NotifyUser, origin, code, urlTmpl string) error {
var url string
if urlTmpl == "" {
url = login.InitPasswordLink(origin, user.ID, code, user.ResourceOwner)
} else {
var buf strings.Builder
if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil {
return err
}
url = buf.String()
}
args := make(map[string]interface{})
args["Code"] = code
return notify(url, args, domain.PasswordResetMessageType, true)

View File

@@ -10,6 +10,7 @@ import (
"github.com/zitadel/zitadel/internal/eventstore/handler/crdb"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
)
const (
@@ -107,6 +108,15 @@ func (p *sessionProjection) reducers() []handler.AggregateReducer {
},
},
},
{
Aggregate: user.AggregateType,
EventRedusers: []handler.EventReducer{
{
Event: user.HumanPasswordChangedType,
Reduce: p.reducePasswordChanged,
},
},
},
}
}
@@ -245,3 +255,21 @@ func (p *sessionProjection) reduceSessionTerminated(event eventstore.Event) (*ha
},
), nil
}
func (p *sessionProjection) reducePasswordChanged(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanPasswordChangedEvent)
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-Deg3d", "reduce.wrong.event.type %s", user.HumanPasswordChangedType)
}
return crdb.NewUpdateStatement(
e,
[]handler.Column{
handler.NewCol(SessionColumnPasswordCheckedAt, nil),
},
[]handler.Condition{
handler.NewCond(SessionColumnUserID, e.Aggregate().ID),
crdb.NewLessThanCond(SessionColumnPasswordCheckedAt, e.CreationDate()),
},
), nil
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/zitadel/zitadel/internal/eventstore/repository"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
)
func TestSessionProjection_reduces(t *testing.T) {
@@ -243,6 +244,39 @@ func TestSessionProjection_reduces(t *testing.T) {
},
},
},
{
name: "reducePasswordChanged",
args: args{
event: getEvent(testEvent(
repository.EventType(user.HumanPasswordChangedType),
user.AggregateType,
[]byte(`{"secret": {
"cryptoType": 0,
"algorithm": "enc",
"keyID": "id",
"crypted": "cGFzc3dvcmQ="
}}`),
), user.HumanPasswordChangedEventMapper),
},
reduce: (&sessionProjection{}).reducePasswordChanged,
want: wantReduce{
aggregateType: eventstore.AggregateType("user"),
sequence: 15,
previousSequence: 10,
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.sessions1 SET password_checked_at = $1 WHERE (user_id = $2) AND (password_checked_at < $3)",
expectedArgs: []interface{}{
nil,
"agg-id",
anyArg{},
},
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@@ -76,6 +76,8 @@ type HumanPasswordCodeAddedEvent struct {
Code *crypto.CryptoValue `json:"code,omitempty"`
Expiry time.Duration `json:"expiry,omitempty"`
NotificationType domain.NotificationType `json:"notificationType,omitempty"`
URLTemplate string `json:"url_template,omitempty"`
CodeReturned bool `json:"code_returned,omitempty"`
}
func (e *HumanPasswordCodeAddedEvent) Data() interface{} {
@@ -92,6 +94,18 @@ func NewHumanPasswordCodeAddedEvent(
code *crypto.CryptoValue,
expiry time.Duration,
notificationType domain.NotificationType,
) *HumanPasswordCodeAddedEvent {
return NewHumanPasswordCodeAddedEventV2(ctx, aggregate, code, expiry, notificationType, "", false)
}
func NewHumanPasswordCodeAddedEventV2(
ctx context.Context,
aggregate *eventstore.Aggregate,
code *crypto.CryptoValue,
expiry time.Duration,
notificationType domain.NotificationType,
urlTemplate string,
codeReturned bool,
) *HumanPasswordCodeAddedEvent {
return &HumanPasswordCodeAddedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
@@ -102,6 +116,8 @@ func NewHumanPasswordCodeAddedEvent(
Code: code,
Expiry: expiry,
NotificationType: notificationType,
URLTemplate: urlTemplate,
CodeReturned: codeReturned,
}
}