From ef012d0081a111abc3cec0e42f47cda865407e34 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Thu, 3 Aug 2023 06:42:59 +0200 Subject: [PATCH] feat: user v2 phone verification (#6309) * feat: add phone change and code verification for user v2 api * feat: add phone change and code verification for user v2 api * fix: add ignored phone.proto * fix: integration tests * Update proto/zitadel/user/v2alpha/user_service.proto * Update idp_template.go --------- Co-authored-by: Livio Spring --- internal/api/grpc/user/v2/phone.go | 61 ++ .../grpc/user/v2/phone_integration_test.go | 171 ++++ internal/api/grpc/user/v2/user.go | 7 +- .../api/grpc/user/v2/user_integration_test.go | 50 +- internal/command/phone.go | 3 + internal/command/user_human.go | 9 +- internal/command/user_human_test.go | 86 ++ internal/command/user_v2_phone.go | 200 +++++ internal/command/user_v2_phone_test.go | 759 ++++++++++++++++++ internal/domain/human_phone.go | 2 + internal/integration/client.go | 6 + .../notification/handlers/user_notifier.go | 4 + internal/repository/user/human_phone.go | 19 +- proto/zitadel/user/v2alpha/phone.proto | 30 + proto/zitadel/user/v2alpha/user_service.proto | 112 +++ 15 files changed, 1511 insertions(+), 8 deletions(-) create mode 100644 internal/api/grpc/user/v2/phone.go create mode 100644 internal/api/grpc/user/v2/phone_integration_test.go create mode 100644 internal/command/user_v2_phone.go create mode 100644 internal/command/user_v2_phone_test.go create mode 100644 proto/zitadel/user/v2alpha/phone.proto diff --git a/internal/api/grpc/user/v2/phone.go b/internal/api/grpc/user/v2/phone.go new file mode 100644 index 0000000000..c6d46e8341 --- /dev/null +++ b/internal/api/grpc/user/v2/phone.go @@ -0,0 +1,61 @@ +package user + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" +) + +func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp *user.SetPhoneResponse, err error) { + var resourceOwner string // TODO: check if still needed + var phone *domain.Phone + + switch v := req.GetVerification().(type) { + case *user.SetPhoneRequest_SendCode: + phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), resourceOwner, req.GetPhone(), s.userCodeAlg) + case *user.SetPhoneRequest_ReturnCode: + phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.GetUserId(), resourceOwner, req.GetPhone(), s.userCodeAlg) + case *user.SetPhoneRequest_IsVerified: + phone, err = s.command.ChangeUserPhoneVerified(ctx, req.GetUserId(), resourceOwner, req.GetPhone()) + case nil: + phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), resourceOwner, req.GetPhone(), s.userCodeAlg) + default: + err = caos_errs.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetPhone not implemented", v) + } + if err != nil { + return nil, err + } + + return &user.SetPhoneResponse{ + Details: &object.Details{ + Sequence: phone.Sequence, + ChangeDate: timestamppb.New(phone.ChangeDate), + ResourceOwner: phone.ResourceOwner, + }, + VerificationCode: phone.PlainCode, + }, nil +} + +func (s *Server) VerifyPhone(ctx context.Context, req *user.VerifyPhoneRequest) (*user.VerifyPhoneResponse, error) { + details, err := s.command.VerifyUserPhone(ctx, + req.GetUserId(), + "", // TODO: check if still needed + req.GetVerificationCode(), + s.userCodeAlg, + ) + if err != nil { + return nil, err + } + return &user.VerifyPhoneResponse{ + Details: &object.Details{ + Sequence: details.Sequence, + ChangeDate: timestamppb.New(details.EventDate), + ResourceOwner: details.ResourceOwner, + }, + }, nil +} diff --git a/internal/api/grpc/user/v2/phone_integration_test.go b/internal/api/grpc/user/v2/phone_integration_test.go new file mode 100644 index 0000000000..5fc75e7cd3 --- /dev/null +++ b/internal/api/grpc/user/v2/phone_integration_test.go @@ -0,0 +1,171 @@ +//go:build integration + +package user_test + +import ( + "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_SetPhone(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + + tests := []struct { + name string + req *user.SetPhoneRequest + want *user.SetPhoneResponse + wantErr bool + }{ + { + name: "default verification", + req: &user.SetPhoneRequest{ + UserId: userID, + Phone: "+41791234568", + }, + want: &user.SetPhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "send verification", + req: &user.SetPhoneRequest{ + UserId: userID, + Phone: "+41791234569", + Verification: &user.SetPhoneRequest_SendCode{ + SendCode: &user.SendPhoneVerificationCode{}, + }, + }, + want: &user.SetPhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "return code", + req: &user.SetPhoneRequest{ + UserId: userID, + Phone: "+41791234566", + Verification: &user.SetPhoneRequest_ReturnCode{ + ReturnCode: &user.ReturnPhoneVerificationCode{}, + }, + }, + want: &user.SetPhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + VerificationCode: gu.Ptr("xxx"), + }, + }, + { + name: "is verified true", + req: &user.SetPhoneRequest{ + UserId: userID, + Phone: "+41791234565", + Verification: &user.SetPhoneRequest_IsVerified{ + IsVerified: true, + }, + }, + want: &user.SetPhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + { + name: "is verified false", + req: &user.SetPhoneRequest{ + UserId: userID, + Phone: "+41791234564", + Verification: &user.SetPhoneRequest_IsVerified{ + IsVerified: false, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.SetPhone(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_VerifyPhone(t *testing.T) { + userResp := Tester.CreateHumanUser(CTX) + tests := []struct { + name string + req *user.VerifyPhoneRequest + want *user.VerifyPhoneResponse + wantErr bool + }{ + { + name: "wrong code", + req: &user.VerifyPhoneRequest{ + UserId: userResp.GetUserId(), + VerificationCode: "xxx", + }, + wantErr: true, + }, + { + name: "wrong user", + req: &user.VerifyPhoneRequest{ + UserId: "xxx", + VerificationCode: userResp.GetPhoneCode(), + }, + wantErr: true, + }, + { + name: "verify user", + req: &user.VerifyPhoneRequest{ + UserId: userResp.GetUserId(), + VerificationCode: userResp.GetPhoneCode(), + }, + want: &user.VerifyPhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.VerifyPhone(CTX, tt.req) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index b0b6f439ea..a5b56014a5 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -32,6 +32,7 @@ func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest UserId: human.ID, Details: object.DomainToDetailsPb(human.Details), EmailCode: human.EmailCode, + PhoneCode: human.PhoneCode, }, nil } @@ -77,9 +78,13 @@ func addUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, ReturnCode: req.GetEmail().GetReturnCode() != nil, URLTemplate: urlTemplate, }, + Phone: command.Phone{ + Number: domain.PhoneNumber(req.GetPhone().GetPhone()), + Verified: req.GetPhone().GetIsVerified(), + ReturnCode: req.GetPhone().GetReturnCode() != nil, + }, PreferredLanguage: language.Make(req.GetProfile().GetPreferredLanguage()), Gender: genderToDomain(req.GetProfile().GetGender()), - Phone: command.Phone{}, // TODO: add as soon as possible Password: req.GetPassword().GetPassword(), EncodedPasswordHash: req.GetHashedPassword().GetHash(), PasswordChangeRequired: passwordChangeRequired, diff --git a/internal/api/grpc/user/v2/user_integration_test.go b/internal/api/grpc/user/v2/user_integration_test.go index a23ebbad9b..ae8145848d 100644 --- a/internal/api/grpc/user/v2/user_integration_test.go +++ b/internal/api/grpc/user/v2/user_integration_test.go @@ -75,6 +75,7 @@ func TestServer_AddHumanUser(t *testing.T) { Gender: user.Gender_GENDER_DIVERSE.Enum(), }, Email: &user.SetHumanEmail{}, + Phone: &user.SetHumanPhone{}, Metadata: []*user.SetMetadataEntry{ { Key: "somekey", @@ -97,7 +98,7 @@ func TestServer_AddHumanUser(t *testing.T) { }, }, { - name: "return verification code", + name: "return email verification code", args: args{ CTX, &user.AddHumanUserRequest{ @@ -187,6 +188,53 @@ func TestServer_AddHumanUser(t *testing.T) { }, }, }, + { + name: "return phone verification code", + args: args{ + CTX, + &user.AddHumanUserRequest{ + Organisation: &object.Organisation{ + Org: &object.Organisation_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + FirstName: "Donald", + LastName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{}, + Phone: &user.SetHumanPhone{ + Phone: "+41791234567", + Verification: &user.SetHumanPhone_ReturnCode{ + ReturnCode: &user.ReturnPhoneVerificationCode{}, + }, + }, + Metadata: []*user.SetMetadataEntry{ + { + Key: "somekey", + Value: []byte("somevalue"), + }, + }, + PasswordType: &user.AddHumanUserRequest_Password{ + Password: &user.Password{ + Password: "DifficultPW666!", + ChangeRequired: true, + }, + }, + }, + }, + want: &user.AddHumanUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + PhoneCode: gu.Ptr("something"), + }, + }, { name: "custom template error", args: args{ diff --git a/internal/command/phone.go b/internal/command/phone.go index 7f550ceeaf..9b0a422b26 100644 --- a/internal/command/phone.go +++ b/internal/command/phone.go @@ -11,6 +11,9 @@ import ( type Phone struct { Number domain.PhoneNumber Verified bool + + // ReturnCode is used if the Verified field is false + ReturnCode bool } func (c *Commands) newPhoneCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCode, error) { diff --git a/internal/command/user_human.go b/internal/command/user_human.go index 1bbaec3ca1..044066aba7 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -66,6 +66,9 @@ type AddHuman struct { // EmailCode is set by the command EmailCode *string + + // PhoneCode is set by the command + PhoneCode *string } type AddLink struct { @@ -258,7 +261,6 @@ func (c *Commands) addHumanCommandEmail(ctx context.Context, filter preparation. if human.Email.Verified { cmds = append(cmds, user.NewHumanEmailVerifiedEvent(ctx, &a.Aggregate)) } - // if allowInitMail, used for v1 api (system, admin, mgmt, auth): // add init code if // email not verified or @@ -302,7 +304,10 @@ func (c *Commands) addHumanCommandPhone(ctx context.Context, filter preparation. if err != nil { return nil, err } - return append(cmds, user.NewHumanPhoneCodeAddedEvent(ctx, &a.Aggregate, phoneCode.Crypted, phoneCode.Expiry)), nil + if human.Phone.ReturnCode { + human.PhoneCode = &phoneCode.Plain + } + return append(cmds, user.NewHumanPhoneCodeAddedEventV2(ctx, &a.Aggregate, phoneCode.Crypted, phoneCode.Expiry, human.Phone.ReturnCode)), nil } func (c *Commands) addHumanCommandCheckID(ctx context.Context, filter preparation.FilterToQueryReducer, human *AddHuman, orgID string) (err error) { diff --git a/internal/command/user_human_test.go b/internal/command/user_human_test.go index 384ef47578..d6964ac90a 100644 --- a/internal/command/user_human_test.go +++ b/internal/command/user_human_test.go @@ -1020,6 +1020,92 @@ func TestCommandSide_AddHuman(t *testing.T) { }, wantID: "user1", }, + }, { + name: "add human (with return code), ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + newAddHumanEvent("$plain$x$password", false, "+41711234567"), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate), + ), + eventFromEventPusher( + user.NewHumanPhoneCodeAddedEventV2( + context.Background(), + &userAgg.Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("phoneCode"), + }, + 1*time.Hour, + true, + ), + ), + }, + uniqueConstraintsFromEventConstraint(user.NewAddUsernameUniqueConstraint("username", "org1", true)), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newCode: mockCode("phoneCode", time.Hour), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + human: &AddHuman{ + Username: "username", + Password: "password", + FirstName: "firstname", + LastName: "lastname", + Email: Email{ + Address: "email@test.ch", + Verified: true, + }, + Phone: Phone{ + Number: "+41711234567", + ReturnCode: true, + }, + PreferredLanguage: language.English, + }, + secretGenerator: GetMockSecretGenerator(t), + allowInitMail: true, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + wantID: "user1", + }, }, { name: "add human with metadata, ok", diff --git a/internal/command/user_v2_phone.go b/internal/command/user_v2_phone.go new file mode 100644 index 0000000000..b8e1174690 --- /dev/null +++ b/internal/command/user_v2_phone.go @@ -0,0 +1,200 @@ +package command + +import ( + "context" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "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" +) + +// ChangeUserPhone sets a user's phone number, generates a code +// and triggers a notification sms. +func (c *Commands) ChangeUserPhone(ctx context.Context, userID, resourceOwner, phone string, alg crypto.EncryptionAlgorithm) (*domain.Phone, error) { + return c.changeUserPhoneWithCode(ctx, userID, resourceOwner, phone, alg, false) +} + +// ChangeUserPhoneReturnCode sets a user's phone number, generates a code and does not send a notification sms. +// The generated plain text code will be set in the returned Phone object. +func (c *Commands) ChangeUserPhoneReturnCode(ctx context.Context, userID, resourceOwner, phone string, alg crypto.EncryptionAlgorithm) (*domain.Phone, error) { + return c.changeUserPhoneWithCode(ctx, userID, resourceOwner, phone, alg, true) +} + +// ChangeUserPhoneVerified sets a user's phone number and marks it is verified. +// No code is generated and no confirmation sms is send. +func (c *Commands) ChangeUserPhoneVerified(ctx context.Context, userID, resourceOwner, phone string) (*domain.Phone, error) { + cmd, err := c.NewUserPhoneEvents(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil { + return nil, err + } + if err = cmd.Change(ctx, domain.PhoneNumber(phone)); err != nil { + return nil, err + } + cmd.SetVerified(ctx) + return cmd.Push(ctx) +} + +func (c *Commands) changeUserPhoneWithCode(ctx context.Context, userID, resourceOwner, phone string, alg crypto.EncryptionAlgorithm, returnCode bool) (*domain.Phone, error) { + config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode) + if err != nil { + return nil, err + } + gen := crypto.NewEncryptionGenerator(*config, alg) + return c.changeUserPhoneWithGenerator(ctx, userID, resourceOwner, phone, gen, returnCode) +} + +// changeUserPhoneWithGenerator set a user's phone number. +// 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 sms will be send to the user. +func (c *Commands) changeUserPhoneWithGenerator(ctx context.Context, userID, resourceOwner, phone string, gen crypto.Generator, returnCode bool) (*domain.Phone, error) { + cmd, err := c.NewUserPhoneEvents(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if authz.GetCtxData(ctx).UserID != userID { + if err = c.checkPermission(ctx, domain.PermissionUserWrite, cmd.aggregate.ResourceOwner, userID); err != nil { + return nil, err + } + } + if err = cmd.Change(ctx, domain.PhoneNumber(phone)); err != nil { + return nil, err + } + if err = cmd.AddGeneratedCode(ctx, gen, returnCode); err != nil { + return nil, err + } + return cmd.Push(ctx) +} + +func (c *Commands) VerifyUserPhone(ctx context.Context, userID, resourceOwner, code string, alg crypto.EncryptionAlgorithm) (*domain.ObjectDetails, error) { + config, err := secretGeneratorConfig(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode) + if err != nil { + return nil, err + } + gen := crypto.NewEncryptionGenerator(*config, alg) + return c.verifyUserPhoneWithGenerator(ctx, userID, resourceOwner, code, gen) +} + +func (c *Commands) verifyUserPhoneWithGenerator(ctx context.Context, userID, resourceOwner, code string, gen crypto.Generator) (*domain.ObjectDetails, error) { + cmd, err := c.NewUserPhoneEvents(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + err = cmd.VerifyCode(ctx, code, gen) + if err != nil { + return nil, err + } + if _, err = cmd.Push(ctx); err != nil { + return nil, err + } + return writeModelToObjectDetails(&cmd.model.WriteModel), nil +} + +// UserPhoneEvents allows step-by-step additions of events, +// operating on the Human Phone Model. +type UserPhoneEvents struct { + eventstore *eventstore.Eventstore + aggregate *eventstore.Aggregate + events []eventstore.Command + model *HumanPhoneWriteModel + + plainCode *string +} + +// NewUserPhoneEvents constructs a UserPhoneEvents with a Human Phone Write Model, +// filtered by userID and resourceOwner. +// If a model cannot be found, or it's state is invalid and error is returned. +func (c *Commands) NewUserPhoneEvents(ctx context.Context, userID, resourceOwner string) (*UserPhoneEvents, error) { + if userID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-xP292j", "Errors.User.Phone.IDMissing") + } + + model, err := c.phoneWriteModelByID(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if model.UserState == domain.UserStateUnspecified || model.UserState == domain.UserStateDeleted { + return nil, caos_errs.ThrowNotFound(nil, "COMMAND-ieJ2e", "Errors.User.Phone.NotFound") + } + if model.UserState == domain.UserStateInitial { + return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-uz0Uu", "Errors.User.NotInitialised") + } + return &UserPhoneEvents{ + eventstore: c.eventstore, + aggregate: UserAggregateFromWriteModel(&model.WriteModel), + model: model, + }, nil +} + +// Change sets a new phone number. +// The generated event unsets any previously generated code and verified flag. +func (c *UserPhoneEvents) Change(ctx context.Context, phone domain.PhoneNumber) error { + phone, err := phone.Normalize() + if err != nil { + return err + } + event, hasChanged := c.model.NewChangedEvent(ctx, c.aggregate, phone) + if !hasChanged { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Uch5e", "Errors.User.Phone.NotChanged") + } + c.events = append(c.events, event) + return nil +} + +// SetVerified sets the phone number to verified. +func (c *UserPhoneEvents) SetVerified(ctx context.Context) { + c.events = append(c.events, user.NewHumanPhoneVerifiedEvent(ctx, c.aggregate)) +} + +// AddGeneratedCode generates a new encrypted code and sets it to the phone number. +// When returnCode a plain text of the code will be returned from Push. +func (c *UserPhoneEvents) AddGeneratedCode(ctx context.Context, gen crypto.Generator, returnCode bool) error { + value, plain, err := crypto.NewCode(gen) + if err != nil { + return err + } + + c.events = append(c.events, user.NewHumanPhoneCodeAddedEventV2(ctx, c.aggregate, value, gen.Expiry(), returnCode)) + if returnCode { + c.plainCode = &plain + } + return nil +} + +func (c *UserPhoneEvents) VerifyCode(ctx context.Context, code string, gen crypto.Generator) error { + if code == "" { + return caos_errs.ThrowInvalidArgument(nil, "COMMAND-Fia4a", "Errors.User.Code.Empty") + } + + err := crypto.VerifyCode(c.model.CodeCreationDate, c.model.CodeExpiry, c.model.Code, code, gen) + if err == nil { + c.events = append(c.events, user.NewHumanPhoneVerifiedEvent(ctx, c.aggregate)) + return nil + } + _, err = c.eventstore.Push(ctx, user.NewHumanPhoneVerificationFailedEvent(ctx, c.aggregate)) + logging.WithFields("id", "COMMAND-Zoo6b", "userID", c.aggregate.ID).OnError(err).Error("NewHumanPhoneVerificationFailedEvent push failed") + return caos_errs.ThrowInvalidArgument(err, "COMMAND-eis9R", "Errors.User.Code.Invalid") +} + +// Push all events to the eventstore and Reduce them into the Model. +func (c *UserPhoneEvents) Push(ctx context.Context) (*domain.Phone, error) { + pushedEvents, err := c.eventstore.Push(ctx, c.events...) + if err != nil { + return nil, err + } + err = AppendAndReduce(c.model, pushedEvents...) + if err != nil { + return nil, err + } + phone := writeModelToPhone(c.model) + phone.PlainCode = c.plainCode + + return phone, nil +} diff --git a/internal/command/user_v2_phone_test.go b/internal/command/user_v2_phone_test.go new file mode 100644 index 0000000000..05da4e260c --- /dev/null +++ b/internal/command/user_v2_phone_test.go @@ -0,0 +1,759 @@ +package command + +import ( + "context" + "testing" + "time" + + "github.com/golang/mock/gomock" + "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/eventstore/repository" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/user" +) + +func TestCommands_ChangeUserPhone(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + userID string + resourceOwner string + phone 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.SecretGeneratorTypeVerifyPhoneCode, + 12, time.Minute, true, true, true, true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "", + }, + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + { + name: "missing phone", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("inst1").Aggregate, + domain.SecretGeneratorTypeVerifyPhoneCode, + 12, time.Minute, true, true, true, true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "", + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "PHONE-Zt0NV", "Errors.User.Phone.Empty"), + }, + { + name: "not changed", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + instance.NewSecretGeneratorAddedEvent(context.Background(), + &instance.NewAggregate("inst1").Aggregate, + domain.SecretGeneratorTypeVerifyPhoneCode, + 12, time.Minute, true, true, true, true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "+41791234567", + }, + wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Uch5e", "Errors.User.Phone.NotChanged"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + _, err := c.ChangeUserPhone(context.Background(), tt.args.userID, tt.args.resourceOwner, tt.args.phone, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) + require.ErrorIs(t, err, tt.wantErr) + // successful cases are tested in TestCommands_changeUserPhoneWithGenerator + }) + } +} + +func TestCommands_ChangeUserPhoneReturnCode(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + userID string + resourceOwner string + phone 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.SecretGeneratorTypeVerifyPhoneCode, + 12, time.Minute, true, true, true, true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "+41791234567", + }, + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + { + name: "missing phone", + 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( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "", + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "PHONE-Zt0NV", "Errors.User.Phone.Empty"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + _, err := c.ChangeUserPhoneReturnCode(context.Background(), tt.args.userID, tt.args.resourceOwner, tt.args.phone, crypto.CreateMockEncryptionAlg(gomock.NewController(t))) + require.ErrorIs(t, err, tt.wantErr) + // successful cases are tested in TestCommands_changeUserPhoneWithGenerator + }) + } +} + +func TestCommands_ChangeUserPhoneVerified(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + userID string + resourceOwner string + phone string + } + tests := []struct { + name string + fields fields + args args + want *domain.Phone + wantErr error + }{ + { + name: "missing userID", + fields: fields{ + eventstore: eventstoreExpect(t), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "", + resourceOwner: "org1", + phone: "+41791234567", + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "COMMAND-xP292j", "Errors.User.Phone.IDMissing"), + }, + { + name: "missing permission", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "+41791234567", + }, + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + { + name: "missing phone", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "", + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "PHONE-Zt0NV", "Errors.User.Phone.Empty"), + }, + { + name: "phone changed", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "+41791234568", + ), + ), + eventFromEventPusher( + user.NewHumanPhoneVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + }, + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "+41791234568", + }, + want: &domain.Phone{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + PhoneNumber: domain.PhoneNumber("+41791234568"), + IsPhoneVerified: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + got, err := c.ChangeUserPhoneVerified(context.Background(), tt.args.userID, tt.args.resourceOwner, tt.args.phone) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, got, tt.want) + }) + } +} + +func TestCommands_changeUserPhoneWithGenerator(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + userID string + resourceOwner string + phone string + returnCode bool + } + tests := []struct { + name string + fields fields + args args + want *domain.Phone + wantErr error + }{ + { + name: "missing user", + fields: fields{ + eventstore: eventstoreExpect(t), + }, + args: args{ + userID: "", + resourceOwner: "org1", + phone: "+41791234567", + returnCode: false, + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "COMMAND-xP292j", "Errors.User.Phone.IDMissing"), + }, + { + name: "missing permission", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "+41791234567", + returnCode: false, + }, + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + { + name: "missing phone", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "", + returnCode: false, + }, + wantErr: caos_errs.ThrowInvalidArgument(nil, "PHONE-Zt0NV", "Errors.User.Phone.Empty"), + }, + { + name: "not changed", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "+41791234567", + returnCode: false, + }, + wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Uch5e", "Errors.User.Phone.NotChanged"), + }, + { + name: "phone changed", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "+41791234568", + ), + ), + eventFromEventPusher( + user.NewHumanPhoneCodeAddedEventV2(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", + resourceOwner: "org1", + phone: "+41791234568", + returnCode: false, + }, + want: &domain.Phone{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + PhoneNumber: "+41791234568", + IsPhoneVerified: false, + }, + }, + { + name: "phone changed, return code", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + func() eventstore.Command { + event := user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) + event.AddPhoneData("+41791234567") + return event + }(), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "+41791234568", + ), + ), + eventFromEventPusher( + user.NewHumanPhoneCodeAddedEventV2(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("a"), + }, + time.Hour*1, + true, + ), + ), + }, + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + userID: "user1", + resourceOwner: "org1", + phone: "+41791234568", + returnCode: true, + }, + want: &domain.Phone{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + PhoneNumber: "+41791234568", + IsPhoneVerified: false, + PlainCode: gu.Ptr("a"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore, + checkPermission: tt.fields.checkPermission, + } + got, err := c.changeUserPhoneWithGenerator(context.Background(), tt.args.userID, tt.args.resourceOwner, tt.args.phone, GetMockSecretGenerator(t), tt.args.returnCode) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, got, tt.want) + }) + } +} diff --git a/internal/domain/human_phone.go b/internal/domain/human_phone.go index e6f8caa6d0..44eb5fe968 100644 --- a/internal/domain/human_phone.go +++ b/internal/domain/human_phone.go @@ -30,6 +30,8 @@ type Phone struct { PhoneNumber PhoneNumber IsPhoneVerified bool + // PlainCode is set by the command and can be used to return it to the caller (API) + PlainCode *string } type PhoneCode struct { diff --git a/internal/integration/client.go b/internal/integration/client.go index 65e0e9ab1b..0daf37c404 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -91,6 +91,12 @@ func (s *Tester) CreateHumanUser(ctx context.Context) *user.AddHumanUserResponse ReturnCode: &user.ReturnEmailVerificationCode{}, }, }, + Phone: &user.SetHumanPhone{ + Phone: "+41791234567", + Verification: &user.SetHumanPhone_ReturnCode{ + ReturnCode: &user.ReturnPhoneVerificationCode{}, + }, + }, }) logging.OnError(err).Fatal("create human user") return resp diff --git a/internal/notification/handlers/user_notifier.go b/internal/notification/handlers/user_notifier.go index d726fb62a1..64661d89bc 100644 --- a/internal/notification/handlers/user_notifier.go +++ b/internal/notification/handlers/user_notifier.go @@ -182,6 +182,7 @@ func (u *userNotifier) reduceEmailCodeAdded(event eventstore.Event) (*handler.St if !ok { return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SWf3g", "reduce.wrong.event.type %s", user.HumanEmailCodeAddedType) } + if e.CodeReturned { return crdb.NewNoOpStatement(e), nil } @@ -535,6 +536,9 @@ func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.St if !ok { return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-He83g", "reduce.wrong.event.type %s", user.HumanPhoneCodeAddedType) } + if e.CodeReturned { + return crdb.NewNoOpStatement(e), nil + } ctx := HandlerContext(event.Aggregate()) alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil, user.UserV1PhoneCodeAddedType, user.UserV1PhoneCodeSentType, diff --git a/internal/repository/user/human_phone.go b/internal/repository/user/human_phone.go index ea5f4ac7c6..c6586c54e5 100644 --- a/internal/repository/user/human_phone.go +++ b/internal/repository/user/human_phone.go @@ -149,8 +149,9 @@ func HumanPhoneVerificationFailedEventMapper(event *repository.Event) (eventstor type HumanPhoneCodeAddedEvent struct { eventstore.BaseEvent `json:"-"` - Code *crypto.CryptoValue `json:"code,omitempty"` - Expiry time.Duration `json:"expiry,omitempty"` + Code *crypto.CryptoValue `json:"code,omitempty"` + Expiry time.Duration `json:"expiry,omitempty"` + CodeReturned bool `json:"code_returned,omitempty"` } func (e *HumanPhoneCodeAddedEvent) Data() interface{} { @@ -166,6 +167,15 @@ func NewHumanPhoneCodeAddedEvent( aggregate *eventstore.Aggregate, code *crypto.CryptoValue, expiry time.Duration, +) *HumanPhoneCodeAddedEvent { + return NewHumanPhoneCodeAddedEventV2(ctx, aggregate, code, expiry, false) +} +func NewHumanPhoneCodeAddedEventV2( + ctx context.Context, + aggregate *eventstore.Aggregate, + code *crypto.CryptoValue, + expiry time.Duration, + codeReturned bool, ) *HumanPhoneCodeAddedEvent { return &HumanPhoneCodeAddedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -173,8 +183,9 @@ func NewHumanPhoneCodeAddedEvent( aggregate, HumanPhoneCodeAddedType, ), - Code: code, - Expiry: expiry, + Code: code, + Expiry: expiry, + CodeReturned: codeReturned, } } diff --git a/proto/zitadel/user/v2alpha/phone.proto b/proto/zitadel/user/v2alpha/phone.proto new file mode 100644 index 0000000000..775bb87300 --- /dev/null +++ b/proto/zitadel/user/v2alpha/phone.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package zitadel.user.v2alpha; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha;user"; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +message SetHumanPhone { + string phone = 1 [ + (validate.rules).string = {min_len: 0, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"+41791234567\""; + } + ]; + oneof verification { + SendPhoneVerificationCode send_code = 2; + ReturnPhoneVerificationCode return_code = 3; + bool is_verified = 4 [(validate.rules).bool.const = true]; + } +} + +message SendPhoneVerificationCode {} + +message ReturnPhoneVerificationCode {} + diff --git a/proto/zitadel/user/v2alpha/user_service.proto b/proto/zitadel/user/v2alpha/user_service.proto index 40e3ea4af5..796688747c 100644 --- a/proto/zitadel/user/v2alpha/user_service.proto +++ b/proto/zitadel/user/v2alpha/user_service.proto @@ -6,6 +6,7 @@ import "zitadel/object/v2alpha/object.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; import "zitadel/user/v2alpha/auth.proto"; import "zitadel/user/v2alpha/email.proto"; +import "zitadel/user/v2alpha/phone.proto"; import "zitadel/user/v2alpha/idp.proto"; import "zitadel/user/v2alpha/password.proto"; import "zitadel/user/v2alpha/user.proto"; @@ -158,6 +159,56 @@ service UserService { }; } + // Change the phone of a user + rpc SetPhone(SetPhoneRequest) returns (SetPhoneResponse) { + option (google.api.http) = { + post: "/v2alpha/users/{user_id}/phone" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Change the user phone"; + description: "Change the phone number of a user. If the state is set to not verified, a verification code will be generated, which can be either returned or sent to the user by sms." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + // Verify the phone with the provided code + rpc VerifyPhone (VerifyPhoneRequest) returns (VerifyPhoneResponse) { + option (google.api.http) = { + post: "/v2alpha/users/{user_id}/phone/_verify" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Verify the phone"; + description: "Verify the phone with the generated code." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + rpc RegisterPasskey (RegisterPasskeyRequest) returns (RegisterPasskeyResponse) { option (google.api.http) = { post: "/v2alpha/users/{user_id}/passkeys" @@ -584,6 +635,7 @@ message AddHumanUserRequest{ (validate.rules).message.required = true, (google.api.field_behavior) = REQUIRED ]; + SetHumanPhone phone = 10; repeated SetMetadataEntry metadata = 6; oneof password_type { Password password = 7; @@ -596,6 +648,7 @@ message AddHumanUserResponse { string user_id = 1; zitadel.object.v2alpha.Details details = 2; optional string email_code = 3; + optional string phone_code = 4; } message SetEmailRequest{ @@ -657,6 +710,65 @@ message VerifyEmailResponse{ zitadel.object.v2alpha.Details details = 1; } +message SetPhoneRequest{ + 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\""; + } + ]; + string phone = 2 [ + (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: "\"+41791234567\""; + } + ]; + // if no verification is specified, an sms is sent + oneof verification { + SendPhoneVerificationCode send_code = 3; + ReturnPhoneVerificationCode return_code = 4; + bool is_verified = 5 [(validate.rules).bool.const = true]; + } +} + +message SetPhoneResponse{ + zitadel.object.v2alpha.Details details = 1; + // in case the verification was set to return_code, the code will be returned + optional string verification_code = 2; +} + +message VerifyPhoneRequest{ + 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\""; + } + ]; + string verification_code = 2 [ + (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 the set phone request\""; + } + ]; +} + +message VerifyPhoneResponse{ + zitadel.object.v2alpha.Details details = 1; +} + message RegisterPasskeyRequest{ string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200},