From 8ec099ae280f1abc31af68821554f31462febe39 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Fri, 27 Dec 2024 16:34:38 +0100 Subject: [PATCH] fix: restructure resend email code to send email code (#9099) # Which Problems Are Solved There is currently no endpoint to send an email code for verification of the email if you don't change the email itself. # How the Problems Are Solved Endpoint HasEmailCode to get the information that an email code is existing, used by the new login. Endpoint SendEmailCode, if no code is existing to replace ResendEmailCode as there is a check that a code has to be there, before it can be resend. # Additional Changes None # Additional Context Closes #9096 --------- Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com> --- internal/api/grpc/user/v2/email.go | 27 ++ .../user/v2/integration_test/email_test.go | 110 +++++ internal/command/user_v2_email.go | 41 +- internal/command/user_v2_email_test.go | 421 +++++++++++++++--- proto/zitadel/user/v2/user_service.proto | 50 ++- 5 files changed, 573 insertions(+), 76 deletions(-) diff --git a/internal/api/grpc/user/v2/email.go b/internal/api/grpc/user/v2/email.go index 6d0871b26e..4b247ef10f 100644 --- a/internal/api/grpc/user/v2/email.go +++ b/internal/api/grpc/user/v2/email.go @@ -67,6 +67,33 @@ func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeR }, nil } +func (s *Server) SendEmailCode(ctx context.Context, req *user.SendEmailCodeRequest) (resp *user.SendEmailCodeResponse, err error) { + var email *domain.Email + + switch v := req.GetVerification().(type) { + case *user.SendEmailCodeRequest_SendCode: + email, err = s.command.SendUserEmailCodeURLTemplate(ctx, req.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + case *user.SendEmailCodeRequest_ReturnCode: + email, err = s.command.SendUserEmailReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + case nil: + email, err = s.command.SendUserEmailCode(ctx, req.GetUserId(), s.userCodeAlg) + default: + err = zerrors.ThrowUnimplementedf(nil, "USERv2-faj0l0nj5x", "verification oneOf %T in method SendEmailCode not implemented", v) + } + if err != nil { + return nil, err + } + + return &user.SendEmailCodeResponse{ + Details: &object.Details{ + Sequence: email.Sequence, + ChangeDate: timestamppb.New(email.ChangeDate), + ResourceOwner: email.ResourceOwner, + }, + VerificationCode: email.PlainCode, + }, nil +} + func (s *Server) VerifyEmail(ctx context.Context, req *user.VerifyEmailRequest) (*user.VerifyEmailResponse, error) { details, err := s.command.VerifyUserEmail(ctx, req.GetUserId(), diff --git a/internal/api/grpc/user/v2/integration_test/email_test.go b/internal/api/grpc/user/v2/integration_test/email_test.go index 37d575016b..de53bc68aa 100644 --- a/internal/api/grpc/user/v2/integration_test/email_test.go +++ b/internal/api/grpc/user/v2/integration_test/email_test.go @@ -249,6 +249,116 @@ func TestServer_ResendEmailCode(t *testing.T) { } } +func TestServer_SendEmailCode(t *testing.T) { + userID := Instance.CreateHumanUser(CTX).GetUserId() + verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email()).GetUserId() + + tests := []struct { + name string + req *user.SendEmailCodeRequest + want *user.SendEmailCodeResponse + wantErr bool + }{ + { + name: "user not existing", + req: &user.SendEmailCodeRequest{ + UserId: "xxx", + }, + wantErr: true, + }, + { + name: "user no code", + req: &user.SendEmailCodeRequest{ + UserId: verifiedUserID, + }, + want: &user.SendEmailCodeResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + { + name: "resend", + req: &user.SendEmailCodeRequest{ + UserId: userID, + }, + want: &user.SendEmailCodeResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + { + name: "custom url template", + req: &user.SendEmailCodeRequest{ + UserId: userID, + Verification: &user.SendEmailCodeRequest_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"), + }, + }, + }, + want: &user.SendEmailCodeResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + { + name: "template error", + req: &user.SendEmailCodeRequest{ + UserId: userID, + Verification: &user.SendEmailCodeRequest_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("{{"), + }, + }, + }, + wantErr: true, + }, + { + name: "return code", + req: &user.SendEmailCodeRequest{ + UserId: userID, + Verification: &user.SendEmailCodeRequest_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + want: &user.SendEmailCodeResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + VerificationCode: gu.Ptr("xxx"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.SendEmailCode(CTX, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + integration.AssertDetails(t, tt.want, got) + if tt.want.GetVerificationCode() != "" { + assert.NotEmpty(t, got.GetVerificationCode()) + } else { + assert.Empty(t, got.GetVerificationCode()) + } + }) + } +} + func TestServer_VerifyEmail(t *testing.T) { userResp := Instance.CreateHumanUser(CTX) tests := []struct { diff --git a/internal/command/user_v2_email.go b/internal/command/user_v2_email.go index 1618e2cd48..4aa75d0935 100644 --- a/internal/command/user_v2_email.go +++ b/internal/command/user_v2_email.go @@ -57,6 +57,28 @@ func (c *Commands) ResendUserEmailReturnCode(ctx context.Context, userID string, return c.resendUserEmailCode(ctx, userID, alg, true, "") } +// SendUserEmailCode generates a new code +// and triggers a notification e-mail with the default confirmation URL format. +func (c *Commands) SendUserEmailCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) { + return c.sendUserEmailCode(ctx, userID, alg, false, "") +} + +// SendUserEmailCodeURLTemplate generates a new 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) SendUserEmailCodeURLTemplate(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm, urlTmpl string) (*domain.Email, error) { + if err := domain.RenderConfirmURLTemplate(io.Discard, urlTmpl, userID, "code", "orgID"); err != nil { + return nil, err + } + return c.sendUserEmailCode(ctx, userID, alg, false, urlTmpl) +} + +// SendUserEmailReturnCode generates a new code and does not send a notification email. +// The generated plain text code will be set in the returned Email object. +func (c *Commands) SendUserEmailReturnCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm) (*domain.Email, error) { + return c.sendUserEmailCode(ctx, userID, alg, true, "") +} + // ChangeUserEmailVerified sets a user's email address and marks it is verified. // No code is generated and no confirmation e-mail is send. func (c *Commands) ChangeUserEmailVerified(ctx context.Context, userID, email string) (*domain.Email, error) { @@ -89,7 +111,16 @@ func (c *Commands) resendUserEmailCode(ctx context.Context, userID string, alg c return nil, err } gen := crypto.NewEncryptionGenerator(*config, alg) - return c.resendUserEmailCodeWithGenerator(ctx, userID, gen, returnCode, urlTmpl) + return c.sendUserEmailCodeWithGenerator(ctx, userID, gen, returnCode, urlTmpl, true) +} + +func (c *Commands) sendUserEmailCode(ctx context.Context, userID string, alg crypto.EncryptionAlgorithm, returnCode bool, urlTmpl string) (*domain.Email, error) { + config, err := cryptoGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyEmailCode) //nolint:staticcheck + if err != nil { + return nil, err + } + gen := crypto.NewEncryptionGenerator(*config, alg) + return c.sendUserEmailCodeWithGenerator(ctx, userID, gen, returnCode, urlTmpl, false) } // changeUserEmailWithGenerator set a user's email address. @@ -104,8 +135,8 @@ func (c *Commands) changeUserEmailWithGenerator(ctx context.Context, userID, ema return cmd.Push(ctx) } -func (c *Commands) resendUserEmailCodeWithGenerator(ctx context.Context, userID string, gen crypto.Generator, returnCode bool, urlTmpl string) (*domain.Email, error) { - cmd, err := c.resendUserEmailCodeWithGeneratorEvents(ctx, userID, gen, returnCode, urlTmpl) +func (c *Commands) sendUserEmailCodeWithGenerator(ctx context.Context, userID string, gen crypto.Generator, returnCode bool, urlTmpl string, existingCheck bool) (*domain.Email, error) { + cmd, err := c.sendUserEmailCodeWithGeneratorEvents(ctx, userID, gen, returnCode, urlTmpl, existingCheck) if err != nil { return nil, err } @@ -129,7 +160,7 @@ func (c *Commands) changeUserEmailWithGeneratorEvents(ctx context.Context, userI return cmd, nil } -func (c *Commands) resendUserEmailCodeWithGeneratorEvents(ctx context.Context, userID string, gen crypto.Generator, returnCode bool, urlTmpl string) (*UserEmailEvents, error) { +func (c *Commands) sendUserEmailCodeWithGeneratorEvents(ctx context.Context, userID string, gen crypto.Generator, returnCode bool, urlTmpl string, existingCheck bool) (*UserEmailEvents, error) { cmd, err := c.NewUserEmailEvents(ctx, userID) if err != nil { return nil, err @@ -137,7 +168,7 @@ func (c *Commands) resendUserEmailCodeWithGeneratorEvents(ctx context.Context, u if err = c.checkPermissionUpdateUser(ctx, cmd.aggregate.ResourceOwner, userID); err != nil { return nil, err } - if cmd.model.Code == nil { + if existingCheck && cmd.model.Code == nil { return nil, zerrors.ThrowPreconditionFailed(err, "EMAIL-5w5ilin4yt", "Errors.User.Code.Empty") } if err = cmd.AddGeneratedCode(ctx, gen, urlTmpl, returnCode); err != nil { diff --git a/internal/command/user_v2_email_test.go b/internal/command/user_v2_email_test.go index 79a53705f8..73ab2e1c4c 100644 --- a/internal/command/user_v2_email_test.go +++ b/internal/command/user_v2_email_test.go @@ -512,6 +512,85 @@ func TestCommands_ResendUserEmailCode(t *testing.T) { } } +func TestCommands_SendUserEmailCode(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + userID string + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "missing permission", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("inst1").Aggregate, + domain.SecretGeneratorTypeVerifyEmailCode, + 12, time.Minute, true, true, true, true, + ), + ), + ), + 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.NewHumanEmailCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "", false, "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "user1", + }, + wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + _, err := c.SendUserEmailCode(context.Background(), tt.args.userID, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) + require.ErrorIs(t, err, tt.wantErr) + // successful cases are tested in TestCommands_sendUserEmailCodeWithGeneratorEvents + }) + } +} + func TestCommands_ResendUserEmailCodeURLTemplate(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore @@ -638,7 +717,99 @@ func TestCommands_ResendUserEmailCodeURLTemplate(t *testing.T) { } _, err := c.ResendUserEmailCodeURLTemplate(context.Background(), tt.args.userID, crypto.CreateMockEncryptionAlg(gomock.NewController(t)), tt.args.urlTmpl) require.ErrorIs(t, err, tt.wantErr) - // successful cases are tested in TestCommands_resendUserEmailCodeWithGenerator + // successful cases are tested in TestCommands_sendUserEmailCodeWithGeneratorEvents + }) + } +} + +func TestCommands_SendUserEmailCodeURLTemplate(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + userID string + urlTmpl string + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "invalid template", + fields: fields{ + eventstore: eventstoreExpect(t), + }, + args: args{ + userID: "user1", + urlTmpl: "{{", + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"), + }, + { + name: "permission missing", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("inst1").Aggregate, + domain.SecretGeneratorTypeVerifyEmailCode, + 12, time.Minute, true, true, true, true, + ), + ), + ), + 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.NewHumanEmailCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "", false, "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "user1", + urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + }, + wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + _, err := c.SendUserEmailCodeURLTemplate(context.Background(), tt.args.userID, crypto.CreateMockEncryptionAlg(gomock.NewController(t)), tt.args.urlTmpl) + require.ErrorIs(t, err, tt.wantErr) + // successful cases are tested in TestCommands_sendUserEmailCodeWithGeneratorEvents }) } } @@ -760,6 +931,85 @@ func TestCommands_ResendUserEmailReturnCode(t *testing.T) { } } +func TestCommands_SendUserEmailReturnCode(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + userID string + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "missing permission", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("inst1").Aggregate, + domain.SecretGeneratorTypeVerifyEmailCode, + 12, time.Minute, true, true, true, true, + ), + ), + ), + 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.NewHumanEmailCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "", false, "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "user1", + }, + wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + _, err := c.SendUserEmailReturnCode(context.Background(), tt.args.userID, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) + require.ErrorIs(t, err, tt.wantErr) + // successful cases are tested in TestCommands_sendUserEmailCodeWithGeneratorEvents + }) + } +} + func TestCommands_ChangeUserEmailVerified(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore @@ -1218,15 +1468,16 @@ func TestCommands_changeUserEmailWithGenerator(t *testing.T) { } } -func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { +func TestCommands_sendUserEmailCodeWithGeneratorEvents(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore checkPermission domain.PermissionCheck } type args struct { - userID string - returnCode bool - urlTmpl string + userID string + returnCode bool + urlTmpl string + checkExisting bool } tests := []struct { name string @@ -1247,37 +1498,6 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { }, wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-0Gzs3", "Errors.User.Email.IDMissing"), }, - { - name: "resend code, missing code", - 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, - ), - ), - ), - ), - checkPermission: newMockPermissionCheckAllowed(), - }, - args: args{ - userID: "user1", - returnCode: false, - urlTmpl: "", - }, - wantErr: zerrors.ThrowPreconditionFailed(nil, "EMAIL-5w5ilin4yt", "Errors.User.Code.Empty"), - }, { name: "missing permission", fields: fields{ @@ -1322,6 +1542,58 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { }, wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), }, + { + name: "send code", + 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, + ), + ), + ), + expectPush( + user.NewHumanEmailCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + "", false, "", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + returnCode: false, + urlTmpl: "", + checkExisting: false, + }, + want: &domain.Email{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + EmailAddress: "email@test.ch", + IsEmailVerified: false, + }, + }, { name: "resend code", fields: fields{ @@ -1373,9 +1645,10 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - userID: "user1", - returnCode: false, - urlTmpl: "", + userID: "user1", + returnCode: false, + urlTmpl: "", + checkExisting: true, }, want: &domain.Email{ ObjectRoot: models.ObjectRoot{ @@ -1387,7 +1660,7 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { }, }, { - name: "resend code, return code", + name: "resend code, missing code", fields: fields{ eventstore: eventstoreExpect( t, @@ -1406,17 +1679,36 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { true, ), ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + returnCode: false, + urlTmpl: "", + checkExisting: true, + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "EMAIL-5w5ilin4yt", "Errors.User.Code.Empty"), + }, + { + name: "send code, return code", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( eventFromEventPusher( - user.NewHumanEmailCodeAddedEventV2(context.Background(), + user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("a"), - }, - time.Hour*1, - "", false, "", + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, ), ), ), @@ -1437,9 +1729,10 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - userID: "user1", - returnCode: true, - urlTmpl: "", + userID: "user1", + returnCode: true, + urlTmpl: "", + checkExisting: false, }, want: &domain.Email{ ObjectRoot: models.ObjectRoot{ @@ -1452,7 +1745,7 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { }, }, { - name: "resend code, URL template", + name: "send code, URL template", fields: fields{ eventstore: eventstoreExpect( t, @@ -1471,19 +1764,6 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { true, ), ), - eventFromEventPusher( - user.NewHumanEmailCodeAddedEventV2(context.Background(), - &user.NewAggregate("user1", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("a"), - }, - time.Hour*1, - "", false, "", - ), - ), ), expectPush( user.NewHumanEmailCodeAddedEventV2(context.Background(), @@ -1502,9 +1782,10 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - userID: "user1", - returnCode: false, - urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + userID: "user1", + returnCode: false, + urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", + checkExisting: false, }, want: &domain.Email{ ObjectRoot: models.ObjectRoot{ @@ -1522,7 +1803,7 @@ func TestCommands_resendUserEmailCodeWithGeneratorEvents(t *testing.T) { eventstore: tt.fields.eventstore, checkPermission: tt.fields.checkPermission, } - got, err := c.resendUserEmailCodeWithGenerator(context.Background(), tt.args.userID, GetMockSecretGenerator(t), tt.args.returnCode, tt.args.urlTmpl) + got, err := c.sendUserEmailCodeWithGenerator(context.Background(), tt.args.userID, GetMockSecretGenerator(t), tt.args.returnCode, tt.args.urlTmpl, tt.args.checkExisting) require.ErrorIs(t, err, tt.wantErr) assert.Equal(t, tt.want, got) }) diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 83b025bf0a..8ae7c1bc08 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -252,9 +252,34 @@ service UserService { }; } + // Send code to verify user email + // + // Send code to verify user email. + rpc SendEmailCode (SendEmailCodeRequest) returns (SendEmailCodeResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/email/send" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + // Verify the email // - // Verify the email with the generated code.. + // Verify the email with the generated code. rpc VerifyEmail (VerifyEmailRequest) returns (VerifyEmailResponse) { option (google.api.http) = { post: "/v2/users/{user_id}/email/verify" @@ -1310,6 +1335,29 @@ message ResendEmailCodeResponse{ optional string verification_code = 2; } +message SendEmailCodeRequest{ + 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 verification is specified, an email is sent with the default url + oneof verification { + SendEmailVerificationCode send_code = 2; + ReturnEmailVerificationCode return_code = 3; + } +} + +message SendEmailCodeResponse{ + zitadel.object.v2.Details details = 1; + // in case the verification was set to return_code, the code will be returned + optional string verification_code = 2; +} + message VerifyEmailRequest{ string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200},