diff --git a/internal/api/grpc/user/v2/phone.go b/internal/api/grpc/user/v2/phone.go index 5024768b35..eac7eb4e31 100644 --- a/internal/api/grpc/user/v2/phone.go +++ b/internal/api/grpc/user/v2/phone.go @@ -40,6 +40,23 @@ func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp }, nil } +func (s *Server) RemovePhone(ctx context.Context, req *user.RemovePhoneRequest) (resp *user.RemovePhoneResponse, err error) { + details, err := s.command.RemoveUserPhone(ctx, + req.GetUserId(), + ) + if err != nil { + return nil, err + } + + return &user.RemovePhoneResponse{ + Details: &object.Details{ + Sequence: details.Sequence, + ChangeDate: timestamppb.New(details.EventDate), + ResourceOwner: details.ResourceOwner, + }, + }, nil +} + func (s *Server) ResendPhoneCode(ctx context.Context, req *user.ResendPhoneCodeRequest) (resp *user.ResendPhoneCodeResponse, err error) { var phone *domain.Phone switch v := req.GetVerification().(type) { diff --git a/internal/api/grpc/user/v2/phone_integration_test.go b/internal/api/grpc/user/v2/phone_integration_test.go index ebd885a47e..e2c670c6bd 100644 --- a/internal/api/grpc/user/v2/phone_integration_test.go +++ b/internal/api/grpc/user/v2/phone_integration_test.go @@ -3,6 +3,7 @@ package user_test import ( + "context" "fmt" "testing" "time" @@ -245,3 +246,99 @@ func TestServer_VerifyPhone(t *testing.T) { }) } } + +func TestServer_RemovePhone(t *testing.T) { + userResp := Tester.CreateHumanUser(CTX) + failResp := Tester.CreateHumanUserNoPhone(CTX) + otherUser := Tester.CreateHumanUser(CTX).GetUserId() + doubleRemoveUser := Tester.CreateHumanUser(CTX) + + Tester.RegisterUserPasskey(CTX, otherUser) + _, sessionTokenOtherUser, _, _ := Tester.CreateVerifiedWebAuthNSession(t, CTX, otherUser) + + tests := []struct { + name string + ctx context.Context + req *user.RemovePhoneRequest + want *user.RemovePhoneResponse + wantErr bool + dep func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) + }{ + { + name: "remove phone", + ctx: CTX, + req: &user.RemovePhoneRequest{ + UserId: userResp.GetUserId(), + }, + want: &user.RemovePhoneResponse{ + Details: &object.Details{ + Sequence: 1, + ChangeDate: timestamppb.Now(), + ResourceOwner: Tester.Organisation.ID, + }, + }, + dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) { + return nil, nil + }, + }, + { + name: "user without phone", + ctx: CTX, + req: &user.RemovePhoneRequest{ + UserId: failResp.GetUserId(), + }, + wantErr: true, + dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) { + return nil, nil + }, + }, + { + name: "remove previously deleted phone", + ctx: CTX, + req: &user.RemovePhoneRequest{ + UserId: doubleRemoveUser.GetUserId(), + }, + wantErr: true, + dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) { + return Client.RemovePhone(ctx, &user.RemovePhoneRequest{ + UserId: doubleRemoveUser.GetUserId(), + }); + }, + }, + { + name: "no user id", + ctx: CTX, + req: &user.RemovePhoneRequest{}, + wantErr: true, + dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) { + return nil, nil + }, + }, + { + name: "other user, no permission", + ctx: Tester.WithAuthorizationToken(CTX, sessionTokenOtherUser), + req: &user.RemovePhoneRequest{ + UserId: userResp.GetUserId(), + }, + wantErr: true, + dep: func(ctx context.Context, userID string) (*user.RemovePhoneResponse, error) { + return nil, nil + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, depErr := tt.dep(tt.ctx, tt.req.UserId) + require.NoError(t, depErr) + + got, err := Client.RemovePhone(tt.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/command/user_v2_phone.go b/internal/command/user_v2_phone.go index b53946c22e..7ebbd4b4d5 100644 --- a/internal/command/user_v2_phone.go +++ b/internal/command/user_v2_phone.go @@ -140,6 +140,29 @@ func (c *Commands) verifyUserPhoneWithGenerator(ctx context.Context, userID, cod return writeModelToObjectDetails(&cmd.model.WriteModel), nil } +func (c *Commands) RemoveUserPhone(ctx context.Context, userID string) (*domain.ObjectDetails, error) { + return c.removeUserPhone(ctx, userID) +} + +func (c *Commands) removeUserPhone(ctx context.Context, userID string) (*domain.ObjectDetails, error) { + cmd, err := c.NewUserPhoneEvents(ctx, userID) + 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.Remove(ctx); 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 { @@ -191,6 +214,14 @@ func (c *UserPhoneEvents) Change(ctx context.Context, phone domain.PhoneNumber) return nil } +func (c *UserPhoneEvents) Remove(ctx context.Context) error { + if c.model.State == domain.PhoneStateRemoved || c.model.State == domain.PhoneStateUnspecified { + return zerrors.ThrowPreconditionFailed(nil, "COMMAND-ieJ2e", "Errors.User.Phone.NotFound") + } + c.events = append(c.events, user.NewHumanPhoneRemovedEvent(ctx, c.aggregate)) + 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)) diff --git a/internal/integration/client.go b/internal/integration/client.go index 783eca4c9b..bd7c8eb400 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -153,6 +153,30 @@ func (s *Tester) CreateHumanUser(ctx context.Context) *user.AddHumanUserResponse return resp } +func (s *Tester) CreateHumanUserNoPhone(ctx context.Context) *user.AddHumanUserResponse { + resp, err := s.Client.UserV2.AddHumanUser(ctx, &user.AddHumanUserRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: s.Organisation.ID, + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + PreferredLanguage: gu.Ptr("nl"), + Gender: gu.Ptr(user.Gender_GENDER_MALE), + }, + Email: &user.SetHumanEmail{ + Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()), + Verification: &user.SetHumanEmail_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + }) + logging.OnError(err).Fatal("create human user") + return resp +} + func (s *Tester) CreateHumanUserWithTOTP(ctx context.Context, secret string) *user.AddHumanUserResponse { resp, err := s.Client.UserV2.AddHumanUser(ctx, &user.AddHumanUserRequest{ Organization: &object.Organization{ diff --git a/proto/zitadel/user/v2beta/user_service.proto b/proto/zitadel/user/v2beta/user_service.proto index cb3a1610e6..08085c231f 100644 --- a/proto/zitadel/user/v2beta/user_service.proto +++ b/proto/zitadel/user/v2beta/user_service.proto @@ -305,6 +305,30 @@ service UserService { }; } + rpc RemovePhone(RemovePhoneRequest) returns (RemovePhoneResponse) { + option (google.api.http) = { + delete: "/v2beta/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: "Delete the user phone"; + description: "Delete the phone number of a user." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + rpc ResendPhoneCode (ResendPhoneCodeRequest) returns (ResendPhoneCodeResponse) { option (google.api.http) = { post: "/v2beta/users/{user_id}/phone/resend" @@ -1124,6 +1148,22 @@ message SetPhoneResponse{ optional string verification_code = 2; } +message RemovePhoneRequest{ + 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\""; + } + ]; +} + +message RemovePhoneResponse{ + zitadel.object.v2beta.Details details = 1; +} + message ResendPhoneCodeRequest{ string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200},