From 82e7333169d76f2d8f6261c51f9448adb79c6f1e Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 20 Jun 2023 17:34:06 +0200 Subject: [PATCH] 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 --- internal/api/grpc/user/v2/password.go | 71 ++ .../grpc/user/v2/password_integration_test.go | 232 +++++++ internal/api/grpc/user/v2/password_test.go | 39 ++ .../api/ui/login/init_password_handler.go | 11 +- internal/command/main_test.go | 6 + internal/command/user_human_password.go | 26 +- internal/command/user_human_password_test.go | 94 ++- internal/command/user_v2_passkey.go | 6 +- internal/command/user_v2_password.go | 71 ++ internal/command/user_v2_password_test.go | 611 ++++++++++++++++++ internal/crypto/code.go | 6 +- internal/crypto/code_test.go | 2 +- internal/eventstore/handler/crdb/statement.go | 13 + .../notification/handlers/usernotifier.go | 5 +- internal/notification/types/password_code.go | 15 +- internal/query/projection/session.go | 28 + internal/query/projection/session_test.go | 34 + internal/repository/user/human_password.go | 16 + proto/zitadel/user/v2alpha/password.proto | 28 +- proto/zitadel/user/v2alpha/user_service.proto | 113 ++++ 20 files changed, 1373 insertions(+), 54 deletions(-) create mode 100644 internal/api/grpc/user/v2/password.go create mode 100644 internal/api/grpc/user/v2/password_integration_test.go create mode 100644 internal/api/grpc/user/v2/password_test.go create mode 100644 internal/command/user_v2_password.go create mode 100644 internal/command/user_v2_password_test.go diff --git a/internal/api/grpc/user/v2/password.go b/internal/api/grpc/user/v2/password.go new file mode 100644 index 0000000000..62a0b4458e --- /dev/null +++ b/internal/api/grpc/user/v2/password.go @@ -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 +} diff --git a/internal/api/grpc/user/v2/password_integration_test.go b/internal/api/grpc/user/v2/password_integration_test.go new file mode 100644 index 0000000000..2c8d50c9a2 --- /dev/null +++ b/internal/api/grpc/user/v2/password_integration_test.go @@ -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) + }) + } +} diff --git a/internal/api/grpc/user/v2/password_test.go b/internal/api/grpc/user/v2/password_test.go new file mode 100644 index 0000000000..b80f0abd3f --- /dev/null +++ b/internal/api/grpc/user/v2/password_test.go @@ -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) + }) + } +} diff --git a/internal/api/ui/login/init_password_handler.go b/internal/api/ui/login/init_password_handler.go index 5418f702eb..aac05bbb92 100644 --- a/internal/api/ui/login/init_password_handler.go +++ b/internal/api/ui/login/init_password_handler.go @@ -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 diff --git a/internal/command/main_test.go b/internal/command/main_test.go index 7b37e0f8d5..184b0a9b04 100644 --- a/internal/command/main_test.go +++ b/internal/command/main_test.go @@ -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 { diff --git a/internal/command/user_human_password.go b/internal/command/user_human_password.go index 706fe2fcc9..8d54727e2c 100644 --- a/internal/command/user_human_password.go +++ b/internal/command/user_human_password.go @@ -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) { diff --git a/internal/command/user_human_password_test.go b/internal/command/user_human_password_test.go index 883959f40a..6eabda9529 100644 --- a/internal/command/user_human_password_test.go +++ b/internal/command/user_human_password_test.go @@ -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) + } }) } } diff --git a/internal/command/user_v2_passkey.go b/internal/command/user_v2_passkey.go index fe2a420921..19b06d97f9 100644 --- a/internal/command/user_v2_passkey.go +++ b/internal/command/user_v2_passkey.go @@ -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) } diff --git a/internal/command/user_v2_password.go b/internal/command/user_v2_password.go new file mode 100644 index 0000000000..53b25c5e4a --- /dev/null +++ b/internal/command/user_v2_password.go @@ -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 +} diff --git a/internal/command/user_v2_password_test.go b/internal/command/user_v2_password_test.go new file mode 100644 index 0000000000..ecf21ff180 --- /dev/null +++ b/internal/command/user_v2_password_test.go @@ -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) + }) + } +} diff --git a/internal/crypto/code.go b/internal/crypto/code.go index 8901b8e27e..60d1496ece 100644 --- a/internal/crypto/code.go +++ b/internal/crypto/code.go @@ -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: diff --git a/internal/crypto/code_test.go b/internal/crypto/code_test.go index 47c173d77a..302ab141a3 100644 --- a/internal/crypto/code_test.go +++ b/internal/crypto/code_test.go @@ -71,7 +71,7 @@ func TestVerifyCode(t *testing.T) { expiry: 5 * time.Minute, cryptoCode: nil, verificationCode: "", - g: nil, + g: createMockGenerator(t, createMockCrypto(t)), }, true, }, diff --git a/internal/eventstore/handler/crdb/statement.go b/internal/eventstore/handler/crdb/statement.go index 8601630cdd..591751e663 100644 --- a/internal/eventstore/handler/crdb/statement.go +++ b/internal/eventstore/handler/crdb/statement.go @@ -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 } diff --git a/internal/notification/handlers/usernotifier.go b/internal/notification/handlers/usernotifier.go index 5cd8102361..cd2a48f09d 100644 --- a/internal/notification/handlers/usernotifier.go +++ b/internal/notification/handlers/usernotifier.go @@ -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 } diff --git a/internal/notification/types/password_code.go b/internal/notification/types/password_code.go index e9b5df529f..2285ba4395 100644 --- a/internal/notification/types/password_code.go +++ b/internal/notification/types/password_code.go @@ -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) diff --git a/internal/query/projection/session.go b/internal/query/projection/session.go index d40302a84f..a0f65a70ac 100644 --- a/internal/query/projection/session.go +++ b/internal/query/projection/session.go @@ -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 +} diff --git a/internal/query/projection/session_test.go b/internal/query/projection/session_test.go index ecb96368c6..2e4a7b6458 100644 --- a/internal/query/projection/session_test.go +++ b/internal/query/projection/session_test.go @@ -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) { diff --git a/internal/repository/user/human_password.go b/internal/repository/user/human_password.go index 26343d664d..ccd38527d4 100644 --- a/internal/repository/user/human_password.go +++ b/internal/repository/user/human_password.go @@ -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, } } diff --git a/proto/zitadel/user/v2alpha/password.proto b/proto/zitadel/user/v2alpha/password.proto index eb168f0436..38089b0b06 100644 --- a/proto/zitadel/user/v2alpha/password.proto +++ b/proto/zitadel/user/v2alpha/password.proto @@ -8,13 +8,6 @@ import "google/api/field_behavior.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; -message SetUserPassword { - oneof type { - Password password = 1; - HashedPassword hashed_password = 2; - } -} - message Password { string password = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -51,3 +44,24 @@ message HashedPassword { ]; bool change_required = 3; } + +message SendPasswordResetLink { + NotificationType notification_type = 1; + optional string url_template = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"https://example.com/password/changey?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}\""; + description: "\"Optionally set a url_template, which will be used in the password reset mail sent by ZITADEL to guide the user to your password change page. If no template is set, the default ZITADEL url will be used.\"" + } + ]; +} + +message ReturnPasswordResetCode {} + +enum NotificationType { + NOTIFICATION_TYPE_Unspecified = 0; + NOTIFICATION_TYPE_Email = 1; + NOTIFICATION_TYPE_SMS = 2; +} diff --git a/proto/zitadel/user/v2alpha/user_service.proto b/proto/zitadel/user/v2alpha/user_service.proto index c4489a04e9..b5f776c6a3 100644 --- a/proto/zitadel/user/v2alpha/user_service.proto +++ b/proto/zitadel/user/v2alpha/user_service.proto @@ -390,6 +390,56 @@ service UserService { }; }; } + + // Request password reset + rpc PasswordReset (PasswordResetRequest) returns (PasswordResetResponse) { + option (google.api.http) = { + post: "/v2alpha/users/{user_id}/password_reset" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Request a code to reset a password"; + description: "Request a code to reset a password"; + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Change password + rpc SetPassword (SetPasswordRequest) returns (SetPasswordResponse) { + option (google.api.http) = { + post: "/v2alpha/users/{user_id}/password" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Request a code to reset a password"; + description: "Request a code to reset a password"; + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } } message AddHumanUserRequest{ @@ -806,3 +856,66 @@ message AddIDPLinkRequest{ message AddIDPLinkResponse { zitadel.object.v2alpha.Details details = 1; } + +message PasswordResetRequest{ + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + // if no medium is specified, an email is sent with the default url + oneof medium { + SendPasswordResetLink send_link = 2; + ReturnPasswordResetCode return_code = 3; + } +} + +message PasswordResetResponse{ + zitadel.object.v2alpha.Details details = 1; + // in case the medium was set to return_code, the code will be returned + optional string verification_code = 2; +} + +message SetPasswordRequest{ + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + Password new_password = 2; + // if neither, the current password must be provided nor a verification code generated by the PasswordReset is provided, + // the user must be granted permission to set a password + oneof verification { + string current_password = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Secr3tP4ssw0rd!\""; + } + ]; + string verification_code = 4 [ + (validate.rules).string = {min_len: 1, max_len: 20}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 20; + example: "\"SKJd342k\""; + description: "\"the verification code generated during password reset request\""; + } + ]; + } +} + +message SetPasswordResponse{ + zitadel.object.v2alpha.Details details = 1; +}