diff --git a/ADOPTERS.md b/ADOPTERS.md index cfd1aac134..da37b9ffb0 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -12,6 +12,7 @@ If you are using Zitadel, please consider adding yourself as a user with a quick | ----------------------- | -------------------------------------------------------------------- | ----------------------------------------------- | | Zitadel | [@fforootd](https://github.com/fforootd) (and many more) | Zitadel Cloud makes heavy use of of Zitadel ;-) | | Rawkode Academy | [@RawkodeAcademy](https://github.com/RawkodeAcademy) | Rawkode Academy Platform & Zulip use Zitadel for all user and M2M authentication | +| XPeditionist | [@XPeditionistTravel](https://github.com/XPeditionistTravel) | An innovative all-in-one travel solution use Zitadel as complete auth solution. | | devOS: Sanity Edition | [@devOS-Sanity-Edition](https://github.com/devOS-Sanity-Edition) | Uses SSO Auth for every piece of our internal and external infrastructure | | CNAP.tech | [@cnap-tech](https://github.com/cnap-tech) | Using Zitadel for authentication and authorization in cloud-native applications | | Minekube | [@minekube](https://github.com/minekube) | Leveraging Zitadel for secure user authentication in gaming infrastructure | diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml index 2b9266c798..94d8f438dc 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml @@ -1,4 +1,3 @@ -version: '3.8' services: traefik: diff --git a/docs/docs/self-hosting/manage/configure/docker-compose.yaml b/docs/docs/self-hosting/manage/configure/docker-compose.yaml index 8e5c9fbc05..abd1818a7b 100644 --- a/docs/docs/self-hosting/manage/configure/docker-compose.yaml +++ b/docs/docs/self-hosting/manage/configure/docker-compose.yaml @@ -1,5 +1,3 @@ -version: "3.8" - services: zitadel: restart: "always" diff --git a/docs/docs/self-hosting/manage/reverseproxy/_proxy_guide_tls_mode.mdx b/docs/docs/self-hosting/manage/reverseproxy/_proxy_guide_tls_mode.mdx index debca2f4f5..1cacf076e5 100644 --- a/docs/docs/self-hosting/manage/reverseproxy/_proxy_guide_tls_mode.mdx +++ b/docs/docs/self-hosting/manage/reverseproxy/_proxy_guide_tls_mode.mdx @@ -24,7 +24,7 @@ export const Description = ({mode, link}) => { } export const Commands = ({mode, name, lower, configfilename}) => { - let genCert = '# Generate a self signed certificate and key.\nopenssl req -x509 -batch -subj "/CN=127.0.0.1.sslip.io/O=ZITADEL Demo" -nodes -newkey rsa:2048 -keyout ./selfsigned.key -out ./selfsigned.crt\n\n'; + let genCert = '# Generate a self signed certificate and key.\nopenssl req -x509 -batch -subj "/CN=127.0.0.1.sslip.io/O=ZITADEL Demo" -nodes -newkey rsa:2048 -keyout ./selfsigned.key -out ./selfsigned.crt 2>/dev/null\n\n'; let connPort = "443" let connInsecureFlag = "--insecure " let connScheme = "https" @@ -42,16 +42,16 @@ export const Commands = ({mode, name, lower, configfilename}) => { {'# Download the configuration files.'}{'\n'} {'export ZITADEL_CONFIG_FILES=https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosting/manage/reverseproxy\n'} - {`wget $\{ZITADEL_CONFIG_FILES\}/docker-compose.yaml -O docker-compose-base.yaml`}{'\n'} - {'wget $\{ZITADEL_CONFIG_FILES\}/'}{lower}{'/docker-compose.yaml -O docker-compose-'}{lower}{'.yaml'}{'\n'} - {'wget $\{ZITADEL_CONFIG_FILES\}/'}{lower}{'/'}{configfilename}{' -O '}{configfilename}{'\n'} + {'wget $\{ZITADEL_CONFIG_FILES\}/docker-compose.yaml -O docker-compose-base.yaml --quiet \n'} + {'wget $\{ZITADEL_CONFIG_FILES\}/'}{lower}{'/docker-compose.yaml -O docker-compose-'}{lower}{'.yaml --quiet \n'} + {'wget $\{ZITADEL_CONFIG_FILES\}/'}{lower}{'/'}{configfilename}{' -O '}{configfilename}{' --quiet \n'} {'\n'} {genCert} {'# Run the database, ZITADEL and '}{name}{'.'}{'\n'} - {'docker compose --file docker-compose-base.yaml --file docker-compose-'}{lower}{'.yaml up --detach proxy-'}{mode}{'-tls'}{'\n'} + {'docker compose --file docker-compose-base.yaml --file docker-compose-'}{lower}{'.yaml up --detach --wait db zitadel-init zitadel-'}{mode}{'-tls proxy-'}{mode}{'-tls'}{'\n'} {'\n'} {'# Test that gRPC and HTTP APIs work. Empty brackets like {} means success.\n'} - {'sleep 3\n'} + {'# Make sure you have the grpcurl cli installed on your machine https://github.com/fullstorydev/grpcurl?tab=readme-ov-file#installation\n'} {'grpcurl '}{connInsecureFlag}{grpcPlainTextFlag}{'127.0.0.1.sslip.io:'}{connPort}{' zitadel.admin.v1.AdminService/Healthz\n'} {'curl '}{connInsecureFlag}{connScheme}{'://127.0.0.1.sslip.io:'}{connPort}{'/admin/v1/healthz\n'} diff --git a/docs/docs/self-hosting/manage/reverseproxy/caddy/docker-compose.yaml b/docs/docs/self-hosting/manage/reverseproxy/caddy/docker-compose.yaml index aa4b7f6869..c5fad6ab7b 100644 --- a/docs/docs/self-hosting/manage/reverseproxy/caddy/docker-compose.yaml +++ b/docs/docs/self-hosting/manage/reverseproxy/caddy/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: proxy-disabled-tls: diff --git a/docs/docs/self-hosting/manage/reverseproxy/docker-compose.yaml b/docs/docs/self-hosting/manage/reverseproxy/docker-compose.yaml index d7d929fa44..989b620fef 100644 --- a/docs/docs/self-hosting/manage/reverseproxy/docker-compose.yaml +++ b/docs/docs/self-hosting/manage/reverseproxy/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: zitadel-disabled-tls: @@ -17,7 +15,7 @@ services: ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel_user ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_pw ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable - ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: root + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable networks: @@ -43,16 +41,16 @@ services: ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel_user ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_pw ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable - ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: root + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable networks: - 'zitadel' depends_on: - zitadel-init: - condition: 'service_completed_successfully' db: condition: 'service_healthy' + zitadel-init: + condition: 'service_completed_successfully' zitadel-enabled-tls: extends: @@ -71,7 +69,7 @@ services: ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel_user ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_pw ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable - ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: root + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable volumes: @@ -109,7 +107,7 @@ services: ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel_user ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel_pw ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable - ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: root + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable networks: @@ -125,10 +123,9 @@ services: restart: 'always' image: postgres:16-alpine environment: - PGUSER: root POSTGRES_PASSWORD: postgres healthcheck: - test: ["CMD-SHELL", "pg_isready", "-d", "zitadel", "-U", "postgres"] + test: ["CMD-SHELL", "pg_isready"] interval: 5s timeout: 60s retries: 10 diff --git a/docs/docs/self-hosting/manage/reverseproxy/httpd/docker-compose.yaml b/docs/docs/self-hosting/manage/reverseproxy/httpd/docker-compose.yaml index 72e06b976f..8757758dc3 100644 --- a/docs/docs/self-hosting/manage/reverseproxy/httpd/docker-compose.yaml +++ b/docs/docs/self-hosting/manage/reverseproxy/httpd/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: proxy-disabled-tls: diff --git a/docs/docs/self-hosting/manage/reverseproxy/nginx/docker-compose.yaml b/docs/docs/self-hosting/manage/reverseproxy/nginx/docker-compose.yaml index 21b3361979..524d50fc30 100644 --- a/docs/docs/self-hosting/manage/reverseproxy/nginx/docker-compose.yaml +++ b/docs/docs/self-hosting/manage/reverseproxy/nginx/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: proxy-disabled-tls: diff --git a/docs/docs/self-hosting/manage/reverseproxy/traefik/docker-compose.yaml b/docs/docs/self-hosting/manage/reverseproxy/traefik/docker-compose.yaml index aee5cf891d..a2dfab075b 100644 --- a/docs/docs/self-hosting/manage/reverseproxy/traefik/docker-compose.yaml +++ b/docs/docs/self-hosting/manage/reverseproxy/traefik/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: proxy-disabled-tls: diff --git a/e2e/config/host.docker.internal/docker-compose.yaml b/e2e/config/host.docker.internal/docker-compose.yaml index 8c9d755b02..80ea33b364 100644 --- a/e2e/config/host.docker.internal/docker-compose.yaml +++ b/e2e/config/host.docker.internal/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: db: diff --git a/e2e/config/localhost/docker-compose.yaml b/e2e/config/localhost/docker-compose.yaml index a14c0dd603..040cbc81c0 100644 --- a/e2e/config/localhost/docker-compose.yaml +++ b/e2e/config/localhost/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: zitadel: user: '$UID' diff --git a/e2e/docker-compose.yaml b/e2e/docker-compose.yaml index ffcfb65c4d..f03b1fcc46 100644 --- a/e2e/docker-compose.yaml +++ b/e2e/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: zitadel: extends: diff --git a/internal/api/grpc/admin/integration_test/iam_member_test.go b/internal/api/grpc/admin/integration_test/iam_member_test.go index 035cfa9f70..93d4417cba 100644 --- a/internal/api/grpc/admin/integration_test/iam_member_test.go +++ b/internal/api/grpc/admin/integration_test/iam_member_test.go @@ -35,7 +35,7 @@ func TestServer_ListIAMMemberRoles(t *testing.T) { } func TestServer_ListIAMMembers(t *testing.T) { - user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) _, err := Client.AddIAMMember(AdminCTX, &admin_pb.AddIAMMemberRequest{ UserId: user.GetUserId(), Roles: iamRoles, @@ -116,7 +116,7 @@ func TestServer_ListIAMMembers(t *testing.T) { } func TestServer_AddIAMMember(t *testing.T) { - user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) type args struct { ctx context.Context req *admin_pb.AddIAMMemberRequest @@ -190,7 +190,7 @@ func TestServer_AddIAMMember(t *testing.T) { } func TestServer_UpdateIAMMember(t *testing.T) { - user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) _, err := Client.AddIAMMember(AdminCTX, &admin_pb.AddIAMMemberRequest{ UserId: user.GetUserId(), Roles: []string{"IAM_OWNER"}, @@ -271,7 +271,7 @@ func TestServer_UpdateIAMMember(t *testing.T) { } func TestServer_RemoveIAMMember(t *testing.T) { - user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(AdminCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) _, err := Client.AddIAMMember(AdminCTX, &admin_pb.AddIAMMemberRequest{ UserId: user.GetUserId(), Roles: []string{"IAM_OWNER"}, diff --git a/internal/api/grpc/management/integration_test/org_test.go b/internal/api/grpc/management/integration_test/org_test.go index 8288ceb9e9..46693f14d7 100644 --- a/internal/api/grpc/management/integration_test/org_test.go +++ b/internal/api/grpc/management/integration_test/org_test.go @@ -39,7 +39,7 @@ func TestServer_ListOrgMemberRoles(t *testing.T) { } func TestServer_ListOrgMembers(t *testing.T) { - user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) _, err := Client.AddOrgMember(OrgCTX, &mgmt_pb.AddOrgMemberRequest{ UserId: user.GetUserId(), Roles: iamRoles[1:], @@ -120,7 +120,7 @@ func TestServer_ListOrgMembers(t *testing.T) { } func TestServer_AddOrgMember(t *testing.T) { - user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) type args struct { ctx context.Context req *mgmt_pb.AddOrgMemberRequest @@ -194,7 +194,7 @@ func TestServer_AddOrgMember(t *testing.T) { } func TestServer_UpdateOrgMember(t *testing.T) { - user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) _, err := Client.AddOrgMember(OrgCTX, &mgmt_pb.AddOrgMemberRequest{ UserId: user.GetUserId(), Roles: []string{"ORG_OWNER"}, @@ -275,7 +275,7 @@ func TestServer_UpdateOrgMember(t *testing.T) { } func TestServer_RemoveIAMMember(t *testing.T) { - user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email()) + user := Instance.CreateHumanUserVerified(OrgCTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()) _, err := Client.AddOrgMember(OrgCTX, &mgmt_pb.AddOrgMemberRequest{ UserId: user.GetUserId(), Roles: []string{"ORG_OWNER"}, diff --git a/internal/api/grpc/object/v2/converter.go b/internal/api/grpc/object/v2/converter.go index fe8aba5d6e..8cf0d8b1fa 100644 --- a/internal/api/grpc/object/v2/converter.go +++ b/internal/api/grpc/object/v2/converter.go @@ -9,6 +9,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/object/v2" + user_pb "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details { @@ -70,3 +71,105 @@ func TextMethodToQuery(method object.TextQueryMethod) query.TextComparison { return -1 } } + +func AuthMethodsToPb(mfas *query.AuthMethods) []*user_pb.AuthFactor { + factors := make([]*user_pb.AuthFactor, len(mfas.AuthMethods)) + for i, mfa := range mfas.AuthMethods { + factors[i] = AuthMethodToPb(mfa) + } + return factors +} + +func AuthMethodToPb(mfa *query.AuthMethod) *user_pb.AuthFactor { + factor := &user_pb.AuthFactor{ + State: MFAStateToPb(mfa.State), + } + switch mfa.Type { + case domain.UserAuthMethodTypeTOTP: + factor.Type = &user_pb.AuthFactor_Otp{ + Otp: &user_pb.AuthFactorOTP{}, + } + case domain.UserAuthMethodTypeU2F: + factor.Type = &user_pb.AuthFactor_U2F{ + U2F: &user_pb.AuthFactorU2F{ + Id: mfa.TokenID, + Name: mfa.Name, + }, + } + case domain.UserAuthMethodTypeOTPSMS: + factor.Type = &user_pb.AuthFactor_OtpSms{ + OtpSms: &user_pb.AuthFactorOTPSMS{}, + } + case domain.UserAuthMethodTypeOTPEmail: + factor.Type = &user_pb.AuthFactor_OtpEmail{ + OtpEmail: &user_pb.AuthFactorOTPEmail{}, + } + case domain.UserAuthMethodTypeUnspecified: + case domain.UserAuthMethodTypePasswordless: + case domain.UserAuthMethodTypePassword: + case domain.UserAuthMethodTypeIDP: + case domain.UserAuthMethodTypeOTP: + case domain.UserAuthMethodTypePrivateKey: + } + return factor +} + +func AuthFactorsToPb(authFactors []user_pb.AuthFactors) []domain.UserAuthMethodType { + factors := make([]domain.UserAuthMethodType, len(authFactors)) + for i, authFactor := range authFactors { + factors[i] = AuthFactorToPb(authFactor) + } + return factors +} + +func AuthFactorToPb(authFactor user_pb.AuthFactors) domain.UserAuthMethodType { + switch authFactor { + case user_pb.AuthFactors_OTP: + return domain.UserAuthMethodTypeTOTP + case user_pb.AuthFactors_OTP_SMS: + return domain.UserAuthMethodTypeOTPSMS + case user_pb.AuthFactors_OTP_EMAIL: + return domain.UserAuthMethodTypeOTPEmail + case user_pb.AuthFactors_U2F: + return domain.UserAuthMethodTypeU2F + default: + return domain.UserAuthMethodTypeUnspecified + } +} + +func AuthFactorStatesToPb(authFactorStates []user_pb.AuthFactorState) []domain.MFAState { + factorStates := make([]domain.MFAState, len(authFactorStates)) + for i, authFactorState := range authFactorStates { + factorStates[i] = AuthFactorStateToPb(authFactorState) + } + return factorStates +} + +func AuthFactorStateToPb(authFactorState user_pb.AuthFactorState) domain.MFAState { + switch authFactorState { + case user_pb.AuthFactorState_AUTH_FACTOR_STATE_UNSPECIFIED: + return domain.MFAStateUnspecified + case user_pb.AuthFactorState_AUTH_FACTOR_STATE_NOT_READY: + return domain.MFAStateNotReady + case user_pb.AuthFactorState_AUTH_FACTOR_STATE_READY: + return domain.MFAStateReady + case user_pb.AuthFactorState_AUTH_FACTOR_STATE_REMOVED: + return domain.MFAStateRemoved + default: + return domain.MFAStateUnspecified + } +} + +func MFAStateToPb(state domain.MFAState) user_pb.AuthFactorState { + switch state { + case domain.MFAStateNotReady: + return user_pb.AuthFactorState_AUTH_FACTOR_STATE_NOT_READY + case domain.MFAStateReady: + return user_pb.AuthFactorState_AUTH_FACTOR_STATE_READY + case domain.MFAStateUnspecified, domain.MFAStateRemoved: + // Handle all remaining cases so the linter succeeds + return user_pb.AuthFactorState_AUTH_FACTOR_STATE_UNSPECIFIED + default: + return user_pb.AuthFactorState_AUTH_FACTOR_STATE_UNSPECIFIED + } +} 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..ad63c2ce5e 100644 --- a/internal/api/grpc/user/v2/integration_test/email_test.go +++ b/internal/api/grpc/user/v2/integration_test/email_test.go @@ -147,7 +147,7 @@ func TestServer_SetEmail(t *testing.T) { func TestServer_ResendEmailCode(t *testing.T) { userID := Instance.CreateHumanUser(CTX).GetUserId() - verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email()).GetUserId() + verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()).GetUserId() tests := []struct { name string @@ -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(), gofakeit.Phone()).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/api/grpc/user/v2/integration_test/idp_link_test.go b/internal/api/grpc/user/v2/integration_test/idp_link_test.go index 116a095216..9d8160ab74 100644 --- a/internal/api/grpc/user/v2/integration_test/idp_link_test.go +++ b/internal/api/grpc/user/v2/integration_test/idp_link_test.go @@ -102,17 +102,17 @@ func TestServer_ListIDPLinks(t *testing.T) { orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListIDPLinks-%s", gofakeit.AppName()), gofakeit.Email()) instanceIdpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) - userInstanceResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email()) + userInstanceResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email(), gofakeit.Phone()) _, err := Instance.CreateUserIDPlink(IamCTX, userInstanceResp.GetUserId(), "external_instance", instanceIdpResp.Id, "externalUsername_instance") require.NoError(t, err) ctxOrg := metadata.AppendToOutgoingContext(IamCTX, "x-zitadel-orgid", orgResp.GetOrganizationId()) orgIdpResp := Instance.AddOrgGenericOAuthProvider(ctxOrg, orgResp.OrganizationId) - userOrgResp := Instance.CreateHumanUserVerified(ctxOrg, orgResp.OrganizationId, gofakeit.Email()) + userOrgResp := Instance.CreateHumanUserVerified(ctxOrg, orgResp.OrganizationId, gofakeit.Email(), gofakeit.Phone()) _, err = Instance.CreateUserIDPlink(ctxOrg, userOrgResp.GetUserId(), "external_org", orgIdpResp.Id, "externalUsername_org") require.NoError(t, err) - userMultipleResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email()) + userMultipleResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email(), gofakeit.Phone()) _, err = Instance.CreateUserIDPlink(IamCTX, userMultipleResp.GetUserId(), "external_multi", instanceIdpResp.Id, "externalUsername_multi") require.NoError(t, err) _, err = Instance.CreateUserIDPlink(ctxOrg, userMultipleResp.GetUserId(), "external_multi", orgIdpResp.Id, "externalUsername_multi") @@ -256,17 +256,17 @@ func TestServer_RemoveIDPLink(t *testing.T) { orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListIDPLinks-%s", gofakeit.AppName()), gofakeit.Email()) instanceIdpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) - userInstanceResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email()) + userInstanceResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email(), gofakeit.Phone()) _, err := Instance.CreateUserIDPlink(IamCTX, userInstanceResp.GetUserId(), "external_instance", instanceIdpResp.Id, "externalUsername_instance") require.NoError(t, err) ctxOrg := metadata.AppendToOutgoingContext(IamCTX, "x-zitadel-orgid", orgResp.GetOrganizationId()) orgIdpResp := Instance.AddOrgGenericOAuthProvider(ctxOrg, orgResp.OrganizationId) - userOrgResp := Instance.CreateHumanUserVerified(ctxOrg, orgResp.OrganizationId, gofakeit.Email()) + userOrgResp := Instance.CreateHumanUserVerified(ctxOrg, orgResp.OrganizationId, gofakeit.Email(), gofakeit.Phone()) _, err = Instance.CreateUserIDPlink(ctxOrg, userOrgResp.GetUserId(), "external_org", orgIdpResp.Id, "externalUsername_org") require.NoError(t, err) - userNoLinkResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email()) + userNoLinkResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email(), gofakeit.Phone()) type args struct { ctx context.Context diff --git a/internal/api/grpc/user/v2/integration_test/phone_test.go b/internal/api/grpc/user/v2/integration_test/phone_test.go index 1c1f75854d..49050c5fe6 100644 --- a/internal/api/grpc/user/v2/integration_test/phone_test.go +++ b/internal/api/grpc/user/v2/integration_test/phone_test.go @@ -123,7 +123,7 @@ func TestServer_SetPhone(t *testing.T) { func TestServer_ResendPhoneCode(t *testing.T) { userID := Instance.CreateHumanUser(CTX).GetUserId() - verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email()).GetUserId() + verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()).GetUserId() tests := []struct { name string diff --git a/internal/api/grpc/user/v2/integration_test/query_test.go b/internal/api/grpc/user/v2/integration_test/query_test.go index a00d1b1a48..2551a4a833 100644 --- a/internal/api/grpc/user/v2/integration_test/query_test.go +++ b/internal/api/grpc/user/v2/integration_test/query_test.go @@ -5,6 +5,7 @@ package user_test import ( "context" "fmt" + "slices" "testing" "time" @@ -24,7 +25,7 @@ func TestServer_GetUserByID(t *testing.T) { type args struct { ctx context.Context req *user.GetUserByIDRequest - dep func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) + dep func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr } tests := []struct { name string @@ -39,8 +40,8 @@ func TestServer_GetUserByID(t *testing.T) { &user.GetUserByIDRequest{ UserId: "", }, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - return nil, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + return nil }, }, wantErr: true, @@ -52,8 +53,8 @@ func TestServer_GetUserByID(t *testing.T) { &user.GetUserByIDRequest{ UserId: "unknown", }, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - return nil, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + return nil }, }, wantErr: true, @@ -63,10 +64,10 @@ func TestServer_GetUserByID(t *testing.T) { args: args{ IamCTX, &user.GetUserByIDRequest{}, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - request.UserId = resp.GetUserId() - return &userAttr{resp.GetUserId(), username, nil, resp.GetDetails()}, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + info := createUser(ctx, orgResp.OrganizationId, false) + request.UserId = info.UserID + return &info }, }, want: &user.GetUserByIDResponse{ @@ -90,7 +91,6 @@ func TestServer_GetUserByID(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -107,11 +107,10 @@ func TestServer_GetUserByID(t *testing.T) { args: args{ IamCTX, &user.GetUserByIDRequest{}, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - request.UserId = resp.GetUserId() - details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) - return &userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()}, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + info := createUser(ctx, orgResp.OrganizationId, true) + request.UserId = info.UserID + return &info }, }, want: &user.GetUserByIDResponse{ @@ -135,7 +134,6 @@ func TestServer_GetUserByID(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, PasswordChangeRequired: true, @@ -152,9 +150,7 @@ func TestServer_GetUserByID(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - username := gofakeit.Email() - userAttr, err := tt.args.dep(tt.args.ctx, username, tt.args.req) - require.NoError(t, err) + userAttr := tt.args.dep(IamCTX, tt.args.req) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) require.EventuallyWithT(t, func(ttt *assert.CollectT) { @@ -174,11 +170,12 @@ func TestServer_GetUserByID(t *testing.T) { tt.want.User.LoginNames = []string{userAttr.Username} if human := tt.want.User.GetHuman(); human != nil { human.Email.Email = userAttr.Username + human.Phone.Phone = userAttr.Phone if tt.want.User.GetHuman().GetPasswordChanged() != nil { human.PasswordChanged = userAttr.Changed } } - assert.Equal(ttt, tt.want.User, got.User) + assert.EqualExportedValues(ttt, tt.want.User, got.User) integration.AssertDetails(ttt, tt.want, got) }, retryDuration, tick) }) @@ -325,21 +322,60 @@ func TestServer_GetUserByID_Permission(t *testing.T) { } } +type userAttrs []userAttr + +func (u userAttrs) userIDs() []string { + ids := make([]string, len(u)) + for i := range u { + ids[i] = u[i].UserID + } + return ids +} + +func (u userAttrs) emails() []string { + emails := make([]string, len(u)) + for i := range u { + emails[i] = u[i].Username + } + return emails +} + type userAttr struct { UserID string Username string + Phone string Changed *timestamppb.Timestamp Details *object.Details } +func createUsers(ctx context.Context, orgID string, count int, passwordChangeRequired bool) userAttrs { + infos := make([]userAttr, count) + for i := 0; i < count; i++ { + infos[i] = createUser(ctx, orgID, passwordChangeRequired) + } + slices.Reverse(infos) + return infos +} + +func createUser(ctx context.Context, orgID string, passwordChangeRequired bool) userAttr { + username := gofakeit.Email() + // used as default country prefix + phone := "+41" + gofakeit.Phone() + resp := Instance.CreateHumanUserVerified(ctx, orgID, username, phone) + info := userAttr{resp.GetUserId(), username, phone, nil, resp.GetDetails()} + if passwordChangeRequired { + details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) + info.Changed = details.GetChangeDate() + } + return info +} + func TestServer_ListUsers(t *testing.T) { orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg-%s", gofakeit.AppName()), gofakeit.Email()) - userResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email()) type args struct { - ctx context.Context - count int - req *user.ListUsersRequest - dep func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) + ctx context.Context + req *user.ListUsersRequest + dep func(ctx context.Context, request *user.ListUsersRequest) userAttrs } tests := []struct { name string @@ -351,11 +387,11 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id, no permission", args: args{ UserCTX, - 0, &user.ListUsersRequest{}, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - request.Queries = append(request.Queries, InUserIDsQuery([]string{userResp.UserId})) - return []userAttr{}, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) + return []userAttr{} }, }, want: &user.ListUsersResponse{ @@ -371,22 +407,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -412,7 +441,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -425,23 +453,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id, passwordChangeRequired, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) - infos[i] = userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, true) + request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -467,7 +487,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, PasswordChangeRequired: true, @@ -482,22 +501,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id multiple, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + infos := createUsers(ctx, orgResp.OrganizationId, 3, false) + request.Queries = append(request.Queries, InUserIDsQuery(infos.userIDs())) + return infos }, }, want: &user.ListUsersResponse{ @@ -523,7 +535,27 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ IsVerified: true, }, }, @@ -544,28 +576,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", - IsVerified: true, - }, - }, - }, - }, { - State: user.UserState_USER_STATE_ACTIVE, - Type: &user.User_Human{ - Human: &user.HumanUser{ - Profile: &user.HumanProfile{ - GivenName: "Mickey", - FamilyName: "Mouse", - NickName: gu.Ptr("Mickey"), - DisplayName: gu.Ptr("Mickey Mouse"), - PreferredLanguage: gu.Ptr("nl"), - Gender: user.Gender_GENDER_MALE.Enum(), - }, - Email: &user.HumanEmail{ - IsVerified: true, - }, - Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -578,22 +588,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by username, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - request.Queries = append(request.Queries, UsernameQuery(username)) - } - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, UsernameQuery(info.Username)) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -619,7 +622,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -632,20 +634,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user in emails, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, InUserEmailsQuery([]string{info.Username})) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -671,7 +668,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -684,20 +680,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user in emails multiple, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + infos := createUsers(ctx, orgResp.OrganizationId, 3, false) + request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails())) + return infos }, }, want: &user.ListUsersResponse{ @@ -723,7 +714,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -744,7 +734,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -765,7 +754,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -778,14 +766,81 @@ func TestServer_ListUsers(t *testing.T) { name: "list user in emails no found, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), InUserEmailsQuery([]string{"notfound"}), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - return []userAttr{}, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + return []userAttr{} + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{}, + }, + }, + { + name: "list user phone, ok", + args: args{ + IamCTX, + &user.ListUsersRequest{ + Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + }, + }, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, PhoneQuery(info.Phone)) + return []userAttr{info} + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, + { + name: "list user in emails no found, ok", + args: args{ + IamCTX, + &user.ListUsersRequest{Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + InUserEmailsQuery([]string{"notfound"}), + }, + }, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + return []userAttr{} }, }, want: &user.ListUsersResponse{ @@ -801,19 +856,14 @@ func TestServer_ListUsers(t *testing.T) { name: "list user resourceowner multiple, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{}, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { orgResp := Instance.CreateOrganization(ctx, fmt.Sprintf("ListUsersResourceowner-%s", gofakeit.AppName()), gofakeit.Email()) - infos := make([]userAttr, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } + infos := createUsers(ctx, orgResp.OrganizationId, 3, false) request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) - request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) - return infos, nil + request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails())) + return infos }, }, want: &user.ListUsersResponse{ @@ -839,7 +889,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -860,7 +909,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -881,7 +929,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -893,12 +940,7 @@ func TestServer_ListUsers(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - usernames := make([]string, tt.args.count) - for i := 0; i < tt.args.count; i++ { - usernames[i] = gofakeit.Email() - } - infos, err := tt.args.dep(tt.args.ctx, usernames, tt.args.req) - require.NoError(t, err) + infos := tt.args.dep(IamCTX, tt.args.req) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) require.EventuallyWithT(t, func(ttt *assert.CollectT) { @@ -924,6 +966,7 @@ func TestServer_ListUsers(t *testing.T) { tt.want.Result[i].LoginNames = []string{infos[i].Username} if human := tt.want.Result[i].GetHuman(); human != nil { human.Email.Email = infos[i].Username + human.Phone.Phone = infos[i].Phone if tt.want.Result[i].GetHuman().GetPasswordChanged() != nil { human.PasswordChanged = infos[i].Changed } @@ -931,7 +974,7 @@ func TestServer_ListUsers(t *testing.T) { tt.want.Result[i].Details = infos[i].Details } for i := range tt.want.Result { - assert.Contains(ttt, got.Result, tt.want.Result[i]) + assert.EqualExportedValues(ttt, got.Result[i], tt.want.Result[i]) } } integration.AssertListDetails(ttt, tt.want, got) @@ -958,6 +1001,15 @@ func InUserEmailsQuery(emails []string) *user.SearchQuery { } } +func PhoneQuery(number string) *user.SearchQuery { + return &user.SearchQuery{Query: &user.SearchQuery_PhoneQuery{ + PhoneQuery: &user.PhoneQuery{ + Number: number, + }, + }, + } +} + func UsernameQuery(username string) *user.SearchQuery { return &user.SearchQuery{Query: &user.SearchQuery_UserNameQuery{ UserNameQuery: &user.UserNameQuery{ diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index 206183351e..8d4c254c6b 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/zitadel/logging" + "github.com/brianvoe/gofakeit/v6" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" @@ -2629,6 +2631,247 @@ func TestServer_ListAuthenticationMethodTypes(t *testing.T) { } } +func TestServer_ListAuthenticationFactors(t *testing.T) { + tests := []struct { + name string + args *user.ListAuthenticationFactorsRequest + want *user.ListAuthenticationFactorsResponse + dep func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) + wantErr bool + ctx context.Context + }{ + { + name: "no auth", + args: &user.ListAuthenticationFactorsRequest{}, + want: &user.ListAuthenticationFactorsResponse{ + Result: nil, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userIDWithoutAuth := Instance.CreateHumanUser(CTX).GetUserId() + args.UserId = userIDWithoutAuth + }, + ctx: CTX, + }, + { + name: "with u2f", + args: &user.ListAuthenticationFactorsRequest{}, + want: &user.ListAuthenticationFactorsResponse{ + Result: []*user.AuthFactor{ + { + State: user.AuthFactorState_AUTH_FACTOR_STATE_READY, + }, + }, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithU2F := Instance.CreateHumanUser(CTX).GetUserId() + U2FId := Instance.RegisterUserU2F(CTX, userWithU2F) + + args.UserId = userWithU2F + want.Result[0].Type = &user.AuthFactor_U2F{ + U2F: &user.AuthFactorU2F{ + Id: U2FId, + Name: "nice name", + }, + } + }, + ctx: CTX, + }, + { + name: "with totp, u2f", + args: &user.ListAuthenticationFactorsRequest{}, + want: &user.ListAuthenticationFactorsResponse{ + Result: []*user.AuthFactor{ + { + State: user.AuthFactorState_AUTH_FACTOR_STATE_READY, + Type: &user.AuthFactor_Otp{ + Otp: &user.AuthFactorOTP{}, + }, + }, + { + State: user.AuthFactorState_AUTH_FACTOR_STATE_READY, + }, + }, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithTOTP := Instance.CreateHumanUserWithTOTP(CTX, "secret").GetUserId() + U2FIdWithTOTP := Instance.RegisterUserU2F(CTX, userWithTOTP) + + args.UserId = userWithTOTP + want.Result[1].Type = &user.AuthFactor_U2F{ + U2F: &user.AuthFactorU2F{ + Id: U2FIdWithTOTP, + Name: "nice name", + }, + } + }, + ctx: CTX, + }, + { + name: "with totp, u2f filtered", + args: &user.ListAuthenticationFactorsRequest{ + AuthFactors: []user.AuthFactors{user.AuthFactors_U2F}, + }, + want: &user.ListAuthenticationFactorsResponse{ + Result: []*user.AuthFactor{ + { + State: user.AuthFactorState_AUTH_FACTOR_STATE_READY, + }, + }, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithTOTP := Instance.CreateHumanUserWithTOTP(CTX, "secret").GetUserId() + U2FIdWithTOTP := Instance.RegisterUserU2F(CTX, userWithTOTP) + + args.UserId = userWithTOTP + want.Result[0].Type = &user.AuthFactor_U2F{ + U2F: &user.AuthFactorU2F{ + Id: U2FIdWithTOTP, + Name: "nice name", + }, + } + }, + ctx: CTX, + }, + { + name: "with sms", + args: &user.ListAuthenticationFactorsRequest{}, + want: &user.ListAuthenticationFactorsResponse{ + Result: []*user.AuthFactor{ + { + State: user.AuthFactorState_AUTH_FACTOR_STATE_READY, + Type: &user.AuthFactor_OtpSms{ + OtpSms: &user.AuthFactorOTPSMS{}, + }, + }, + }, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithSMS := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.GetId(), gofakeit.Email(), gofakeit.Phone()).GetUserId() + Instance.RegisterUserOTPSMS(CTX, userWithSMS) + + args.UserId = userWithSMS + }, + ctx: CTX, + }, + { + name: "with email", + args: &user.ListAuthenticationFactorsRequest{}, + want: &user.ListAuthenticationFactorsResponse{ + Result: []*user.AuthFactor{ + { + State: user.AuthFactorState_AUTH_FACTOR_STATE_READY, + Type: &user.AuthFactor_OtpEmail{ + OtpEmail: &user.AuthFactorOTPEmail{}, + }, + }, + }, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithEmail := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.GetId(), gofakeit.Email(), gofakeit.Phone()).GetUserId() + Instance.RegisterUserOTPEmail(CTX, userWithEmail) + + args.UserId = userWithEmail + }, + ctx: CTX, + }, + { + name: "with not ready u2f", + args: &user.ListAuthenticationFactorsRequest{}, + want: &user.ListAuthenticationFactorsResponse{ + Result: []*user.AuthFactor{}, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithNotReadyU2F := Instance.CreateHumanUser(CTX).GetUserId() + _, err := Instance.Client.UserV2.RegisterU2F(CTX, &user.RegisterU2FRequest{ + UserId: userWithNotReadyU2F, + Domain: Instance.Domain, + }) + logging.OnError(err).Panic("Could not register u2f") + + args.UserId = userWithNotReadyU2F + }, + ctx: CTX, + }, + { + name: "with not ready u2f state filtered", + args: &user.ListAuthenticationFactorsRequest{ + States: []user.AuthFactorState{user.AuthFactorState_AUTH_FACTOR_STATE_NOT_READY}, + }, + want: &user.ListAuthenticationFactorsResponse{ + Result: []*user.AuthFactor{ + { + State: user.AuthFactorState_AUTH_FACTOR_STATE_NOT_READY, + }, + }, + }, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithNotReadyU2F := Instance.CreateHumanUser(CTX).GetUserId() + U2FNotReady, err := Instance.Client.UserV2.RegisterU2F(CTX, &user.RegisterU2FRequest{ + UserId: userWithNotReadyU2F, + Domain: Instance.Domain, + }) + logging.OnError(err).Panic("Could not register u2f") + + args.UserId = userWithNotReadyU2F + want.Result[0].Type = &user.AuthFactor_U2F{ + U2F: &user.AuthFactorU2F{ + Id: U2FNotReady.GetU2FId(), + Name: "", + }, + } + }, + ctx: CTX, + }, + { + name: "with no userId", + args: &user.ListAuthenticationFactorsRequest{ + UserId: "", + }, + ctx: CTX, + wantErr: true, + }, + { + name: "with no permission", + args: &user.ListAuthenticationFactorsRequest{}, + dep: func(args *user.ListAuthenticationFactorsRequest, want *user.ListAuthenticationFactorsResponse) { + userWithTOTP := Instance.CreateHumanUserWithTOTP(CTX, "totp").GetUserId() + + args.UserId = userWithTOTP + }, + ctx: UserCTX, + wantErr: true, + }, + { + name: "with unknown user", + args: &user.ListAuthenticationFactorsRequest{ + UserId: "unknown", + }, + want: &user.ListAuthenticationFactorsResponse{}, + ctx: CTX, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dep != nil { + tt.dep(tt.args, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := Client.ListAuthenticationFactors(tt.ctx, tt.args) + if tt.wantErr { + require.Error(ttt, err) + return + } + require.NoError(ttt, err) + + assert.ElementsMatch(t, tt.want.GetResult(), got.GetResult()) + }, retryDuration, tick, "timeout waiting for expected auth methods result") + + }) + } +} + func TestServer_CreateInviteCode(t *testing.T) { type args struct { ctx context.Context diff --git a/internal/api/grpc/user/v2/query.go b/internal/api/grpc/user/v2/query.go index 564d4c1c0a..4cfbb46a51 100644 --- a/internal/api/grpc/user/v2/query.go +++ b/internal/api/grpc/user/v2/query.go @@ -238,6 +238,8 @@ func userQueryToQuery(query *user.SearchQuery, level uint8) (query.SearchQuery, return displayNameQueryToQuery(q.DisplayNameQuery) case *user.SearchQuery_EmailQuery: return emailQueryToQuery(q.EmailQuery) + case *user.SearchQuery_PhoneQuery: + return phoneQueryToQuery(q.PhoneQuery) case *user.SearchQuery_StateQuery: return stateQueryToQuery(q.StateQuery) case *user.SearchQuery_TypeQuery: @@ -285,6 +287,10 @@ func emailQueryToQuery(q *user.EmailQuery) (query.SearchQuery, error) { return query.NewUserEmailSearchQuery(q.EmailAddress, object.TextMethodToQuery(q.Method)) } +func phoneQueryToQuery(q *user.PhoneQuery) (query.SearchQuery, error) { + return query.NewUserPhoneSearchQuery(q.Number, object.TextMethodToQuery(q.Method)) +} + func stateQueryToQuery(q *user.StateQuery) (query.SearchQuery, error) { return query.NewUserStateSearchQuery(int32(q.State)) } diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index c0416f84aa..9d99f210e5 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -597,6 +597,39 @@ func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *user.Li }, nil } +func (s *Server) ListAuthenticationFactors(ctx context.Context, req *user.ListAuthenticationFactorsRequest) (*user.ListAuthenticationFactorsResponse, error) { + query := new(query.UserAuthMethodSearchQueries) + + if err := query.AppendUserIDQuery(req.UserId); err != nil { + return nil, err + } + + authMethodsType := []domain.UserAuthMethodType{domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPSMS, domain.UserAuthMethodTypeOTPEmail} + if len(req.GetAuthFactors()) > 0 { + authMethodsType = object.AuthFactorsToPb(req.GetAuthFactors()) + } + if err := query.AppendAuthMethodsQuery(authMethodsType...); err != nil { + return nil, err + } + + states := []domain.MFAState{domain.MFAStateReady} + if len(req.GetStates()) > 0 { + states = object.AuthFactorStatesToPb(req.GetStates()) + } + if err := query.AppendStatesQuery(states...); err != nil { + return nil, err + } + + authMethods, err := s.query.SearchUserAuthMethods(ctx, query, s.checkPermission) + if err != nil { + return nil, err + } + + return &user.ListAuthenticationFactorsResponse{ + Result: object.AuthMethodsToPb(authMethods), + }, nil +} + func authMethodTypesToPb(methodTypes []domain.UserAuthMethodType) []user.AuthenticationMethodType { methods := make([]user.AuthenticationMethodType, len(methodTypes)) for i, method := range methodTypes { diff --git a/internal/api/grpc/user/v2beta/integration_test/email_test.go b/internal/api/grpc/user/v2beta/integration_test/email_test.go index d22355978a..48957e99d9 100644 --- a/internal/api/grpc/user/v2beta/integration_test/email_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/email_test.go @@ -147,7 +147,7 @@ func TestServer_SetEmail(t *testing.T) { func TestServer_ResendEmailCode(t *testing.T) { userID := Instance.CreateHumanUser(CTX).GetUserId() - verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email()).GetUserId() + verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()).GetUserId() tests := []struct { name string diff --git a/internal/api/grpc/user/v2beta/integration_test/phone_test.go b/internal/api/grpc/user/v2beta/integration_test/phone_test.go index cd7199dcea..73d065231c 100644 --- a/internal/api/grpc/user/v2beta/integration_test/phone_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/phone_test.go @@ -125,7 +125,7 @@ func TestServer_SetPhone(t *testing.T) { func TestServer_ResendPhoneCode(t *testing.T) { userID := Instance.CreateHumanUser(CTX).GetUserId() - verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email()).GetUserId() + verifiedUserID := Instance.CreateHumanUserVerified(CTX, Instance.DefaultOrg.Id, gofakeit.Email(), gofakeit.Phone()).GetUserId() tests := []struct { name string diff --git a/internal/api/grpc/user/v2beta/integration_test/query_test.go b/internal/api/grpc/user/v2beta/integration_test/query_test.go index fc1d71926e..67fc609212 100644 --- a/internal/api/grpc/user/v2beta/integration_test/query_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/query_test.go @@ -5,6 +5,7 @@ package user_test import ( "context" "fmt" + "slices" "testing" "time" @@ -33,7 +34,7 @@ func TestServer_GetUserByID(t *testing.T) { type args struct { ctx context.Context req *user.GetUserByIDRequest - dep func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) + dep func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr } tests := []struct { name string @@ -48,8 +49,8 @@ func TestServer_GetUserByID(t *testing.T) { &user.GetUserByIDRequest{ UserId: "", }, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - return nil, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + return nil }, }, wantErr: true, @@ -61,8 +62,8 @@ func TestServer_GetUserByID(t *testing.T) { &user.GetUserByIDRequest{ UserId: "unknown", }, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - return nil, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + return nil }, }, wantErr: true, @@ -72,10 +73,10 @@ func TestServer_GetUserByID(t *testing.T) { args: args{ IamCTX, &user.GetUserByIDRequest{}, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - request.UserId = resp.GetUserId() - return &userAttr{resp.GetUserId(), username, nil, resp.GetDetails()}, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + info := createUser(ctx, orgResp.OrganizationId, false) + request.UserId = info.UserID + return &info }, }, want: &user.GetUserByIDResponse{ @@ -99,7 +100,6 @@ func TestServer_GetUserByID(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -116,11 +116,10 @@ func TestServer_GetUserByID(t *testing.T) { args: args{ IamCTX, &user.GetUserByIDRequest{}, - func(ctx context.Context, username string, request *user.GetUserByIDRequest) (*userAttr, error) { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - request.UserId = resp.GetUserId() - details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) - return &userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()}, nil + func(ctx context.Context, request *user.GetUserByIDRequest) *userAttr { + info := createUser(ctx, orgResp.OrganizationId, true) + request.UserId = info.UserID + return &info }, }, want: &user.GetUserByIDResponse{ @@ -144,7 +143,6 @@ func TestServer_GetUserByID(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, PasswordChangeRequired: true, @@ -161,9 +159,7 @@ func TestServer_GetUserByID(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - username := gofakeit.Email() - userAttr, err := tt.args.dep(tt.args.ctx, username, tt.args.req) - require.NoError(t, err) + userAttr := tt.args.dep(IamCTX, tt.args.req) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) require.EventuallyWithT(t, func(ttt *assert.CollectT) { @@ -183,6 +179,7 @@ func TestServer_GetUserByID(t *testing.T) { tt.want.User.LoginNames = []string{userAttr.Username} if human := tt.want.User.GetHuman(); human != nil { human.Email.Email = userAttr.Username + human.Phone.Phone = userAttr.Phone if tt.want.User.GetHuman().GetPasswordChanged() != nil { human.PasswordChanged = userAttr.Changed } @@ -335,21 +332,60 @@ func TestServer_GetUserByID_Permission(t *testing.T) { } } +type userAttrs []userAttr + +func (u userAttrs) userIDs() []string { + ids := make([]string, len(u)) + for i := range u { + ids[i] = u[i].UserID + } + return ids +} + +func (u userAttrs) emails() []string { + emails := make([]string, len(u)) + for i := range u { + emails[i] = u[i].Username + } + return emails +} + type userAttr struct { UserID string Username string + Phone string Changed *timestamppb.Timestamp Details *object.Details } +func createUsers(ctx context.Context, orgID string, count int, passwordChangeRequired bool) userAttrs { + infos := make([]userAttr, count) + for i := 0; i < count; i++ { + infos[i] = createUser(ctx, orgID, passwordChangeRequired) + } + slices.Reverse(infos) + return infos +} + +func createUser(ctx context.Context, orgID string, passwordChangeRequired bool) userAttr { + username := gofakeit.Email() + // used as default country prefix + phone := "+41" + gofakeit.Phone() + resp := Instance.CreateHumanUserVerified(ctx, orgID, username, phone) + info := userAttr{resp.GetUserId(), username, phone, nil, resp.GetDetails()} + if passwordChangeRequired { + details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) + info.Changed = details.GetChangeDate() + } + return info +} + func TestServer_ListUsers(t *testing.T) { orgResp := Instance.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg-%s", gofakeit.AppName()), gofakeit.Email()) - userResp := Instance.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, gofakeit.Email()) type args struct { - ctx context.Context - count int - req *user.ListUsersRequest - dep func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) + ctx context.Context + req *user.ListUsersRequest + dep func(ctx context.Context, request *user.ListUsersRequest) userAttrs } tests := []struct { name string @@ -361,11 +397,11 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id, no permission", args: args{ UserCTX, - 0, &user.ListUsersRequest{}, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - request.Queries = append(request.Queries, InUserIDsQuery([]string{userResp.UserId})) - return []userAttr{}, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) + return []userAttr{} }, }, want: &user.ListUsersResponse{ @@ -381,22 +417,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -422,7 +451,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -435,23 +463,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id, passwordChangeRequired, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - details := Instance.SetUserPassword(ctx, resp.GetUserId(), integration.UserPassword, true) - infos[i] = userAttr{resp.GetUserId(), username, details.GetChangeDate(), resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, true) + request.Queries = append(request.Queries, InUserIDsQuery([]string{info.UserID})) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -477,7 +497,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, PasswordChangeRequired: true, @@ -492,22 +511,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by id multiple, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + infos := createUsers(ctx, orgResp.OrganizationId, 3, false) + request.Queries = append(request.Queries, InUserIDsQuery(infos.userIDs())) + return infos }, }, want: &user.ListUsersResponse{ @@ -533,7 +545,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -554,7 +565,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -575,7 +585,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -588,22 +597,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user by username, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - userIDs := make([]string, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - userIDs[i] = resp.GetUserId() - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - request.Queries = append(request.Queries, UsernameQuery(username)) - } - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, UsernameQuery(info.Username)) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -629,7 +631,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -642,20 +643,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user in emails, ok", args: args{ IamCTX, - 1, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, InUserEmailsQuery([]string{info.Username})) + return []userAttr{info} }, }, want: &user.ListUsersResponse{ @@ -681,7 +677,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -694,20 +689,15 @@ func TestServer_ListUsers(t *testing.T) { name: "list user in emails multiple, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{ Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - infos := make([]userAttr, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } - request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) - return infos, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + infos := createUsers(ctx, orgResp.OrganizationId, 3, false) + request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails())) + return infos }, }, want: &user.ListUsersResponse{ @@ -733,7 +723,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -754,7 +743,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -775,7 +763,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -788,14 +775,13 @@ func TestServer_ListUsers(t *testing.T) { name: "list user in emails no found, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{Queries: []*user.SearchQuery{ OrganizationIdQuery(orgResp.OrganizationId), InUserEmailsQuery([]string{"notfound"}), }, }, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { - return []userAttr{}, nil + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + return []userAttr{} }, }, want: &user.ListUsersResponse{ @@ -807,23 +793,64 @@ func TestServer_ListUsers(t *testing.T) { Result: []*user.User{}, }, }, + { + name: "list user phone, ok", + args: args{ + IamCTX, + &user.ListUsersRequest{ + Queries: []*user.SearchQuery{ + OrganizationIdQuery(orgResp.OrganizationId), + }, + }, + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { + info := createUser(ctx, orgResp.OrganizationId, false) + request.Queries = append(request.Queries, PhoneQuery(info.Phone)) + return []userAttr{info} + }, + }, + want: &user.ListUsersResponse{ + Details: &object_v2beta.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, { name: "list user resourceowner multiple, ok", args: args{ IamCTX, - 3, &user.ListUsersRequest{}, - func(ctx context.Context, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + func(ctx context.Context, request *user.ListUsersRequest) userAttrs { orgResp := Instance.CreateOrganization(ctx, fmt.Sprintf("ListUsersResourceowner-%s", gofakeit.AppName()), gofakeit.Email()) - infos := make([]userAttr, len(usernames)) - for i, username := range usernames { - resp := Instance.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) - infos[i] = userAttr{resp.GetUserId(), username, nil, resp.GetDetails()} - } + infos := createUsers(ctx, orgResp.OrganizationId, 3, false) request.Queries = append(request.Queries, OrganizationIdQuery(orgResp.OrganizationId)) - request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) - return infos, nil + request.Queries = append(request.Queries, InUserEmailsQuery(infos.emails())) + return infos }, }, want: &user.ListUsersResponse{ @@ -849,7 +876,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -870,7 +896,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -891,7 +916,6 @@ func TestServer_ListUsers(t *testing.T) { IsVerified: true, }, Phone: &user.HumanPhone{ - Phone: "+41791234567", IsVerified: true, }, }, @@ -903,12 +927,7 @@ func TestServer_ListUsers(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - usernames := make([]string, tt.args.count) - for i := 0; i < tt.args.count; i++ { - usernames[i] = gofakeit.Email() - } - infos, err := tt.args.dep(tt.args.ctx, usernames, tt.args.req) - require.NoError(t, err) + infos := tt.args.dep(IamCTX, tt.args.req) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) require.EventuallyWithT(t, func(ttt *assert.CollectT) { @@ -934,6 +953,7 @@ func TestServer_ListUsers(t *testing.T) { tt.want.Result[i].LoginNames = []string{infos[i].Username} if human := tt.want.Result[i].GetHuman(); human != nil { human.Email.Email = infos[i].Username + human.Phone.Phone = infos[i].Phone if tt.want.Result[i].GetHuman().GetPasswordChanged() != nil { human.PasswordChanged = infos[i].Changed } @@ -941,7 +961,7 @@ func TestServer_ListUsers(t *testing.T) { tt.want.Result[i].Details = detailsV2ToV2beta(infos[i].Details) } for i := range tt.want.Result { - assert.Contains(ttt, got.Result, tt.want.Result[i]) + assert.EqualExportedValues(ttt, got.Result[i], tt.want.Result[i]) } } integration.AssertListDetails(ttt, tt.want, got) @@ -968,6 +988,15 @@ func InUserEmailsQuery(emails []string) *user.SearchQuery { } } +func PhoneQuery(number string) *user.SearchQuery { + return &user.SearchQuery{Query: &user.SearchQuery_PhoneQuery{ + PhoneQuery: &user.PhoneQuery{ + Number: number, + }, + }, + } +} + func UsernameQuery(username string) *user.SearchQuery { return &user.SearchQuery{Query: &user.SearchQuery_UserNameQuery{ UserNameQuery: &user.UserNameQuery{ diff --git a/internal/api/grpc/user/v2beta/query.go b/internal/api/grpc/user/v2beta/query.go index 4567259d15..e3602abc33 100644 --- a/internal/api/grpc/user/v2beta/query.go +++ b/internal/api/grpc/user/v2beta/query.go @@ -238,6 +238,8 @@ func userQueryToQuery(query *user.SearchQuery, level uint8) (query.SearchQuery, return displayNameQueryToQuery(q.DisplayNameQuery) case *user.SearchQuery_EmailQuery: return emailQueryToQuery(q.EmailQuery) + case *user.SearchQuery_PhoneQuery: + return phoneQueryToQuery(q.PhoneQuery) case *user.SearchQuery_StateQuery: return stateQueryToQuery(q.StateQuery) case *user.SearchQuery_TypeQuery: @@ -285,6 +287,10 @@ func emailQueryToQuery(q *user.EmailQuery) (query.SearchQuery, error) { return query.NewUserEmailSearchQuery(q.EmailAddress, object.TextMethodToQuery(q.Method)) } +func phoneQueryToQuery(q *user.PhoneQuery) (query.SearchQuery, error) { + return query.NewUserPhoneSearchQuery(q.Number, object.TextMethodToQuery(q.Method)) +} + func stateQueryToQuery(q *user.StateQuery) (query.SearchQuery, error) { return query.NewUserStateSearchQuery(int32(q.State)) } 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/internal/i18n/translator.go b/internal/i18n/translator.go index 74dd65663a..d9d385576d 100644 --- a/internal/i18n/translator.go +++ b/internal/i18n/translator.go @@ -64,10 +64,22 @@ func (t *Translator) SupportedLanguages() []language.Tag { return t.allowedLanguages } +// AddMessages adds messages to the translator for the given language tag. +// If the tag is not in the allowed languages, the messages are not added. func (t *Translator) AddMessages(tag language.Tag, messages ...Message) error { if len(messages) == 0 { return nil } + var isAllowed bool + for _, allowed := range t.allowedLanguages { + if allowed == tag { + isAllowed = true + break + } + } + if !isAllowed { + return nil + } i18nMessages := make([]*i18n.Message, len(messages)) for i, message := range messages { i18nMessages[i] = &i18n.Message{ diff --git a/internal/idp/providers/saml/saml.go b/internal/idp/providers/saml/saml.go index e0391bc099..11b1d36da2 100644 --- a/internal/idp/providers/saml/saml.go +++ b/internal/idp/providers/saml/saml.go @@ -126,7 +126,7 @@ func ParseMetadata(metadata []byte) (*saml.EntityDescriptor, error) { if _, err := reader.Seek(0, io.SeekStart); err != nil { return nil, err } - entities := &saml.EntitiesDescriptor{} + entities := &EntitiesDescriptor{} if err := decoder.Decode(entities); err != nil { return nil, err } @@ -253,3 +253,26 @@ func nameIDFormatFromDomain(format domain.SAMLNameIDFormat) saml.NameIDFormat { return saml.UnspecifiedNameIDFormat } } + +// EntitiesDescriptor is a workaround until we eventually fork the crewjam/saml library, since maintenance on that repo seems to have stopped. +// This is to be able to handle xsd:duration format using the UnmarshalXML method. +// crewjam/saml only implements the xsd:dateTime format for EntityDescriptor, but not EntitiesDescriptor. +type EntitiesDescriptor saml.EntitiesDescriptor + +// UnmarshalXML implements xml.Unmarshaler +func (m *EntitiesDescriptor) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + type Alias EntitiesDescriptor + aux := &struct { + ValidUntil *saml.RelaxedTime `xml:"validUntil,attr,omitempty"` + CacheDuration *saml.Duration `xml:"cacheDuration,attr,omitempty"` + *Alias + }{ + Alias: (*Alias)(m), + } + if err := d.DecodeElement(aux, &start); err != nil { + return err + } + m.ValidUntil = (*time.Time)(aux.ValidUntil) + m.CacheDuration = (*time.Duration)(aux.CacheDuration) + return nil +} diff --git a/internal/idp/providers/saml/saml_test.go b/internal/idp/providers/saml/saml_test.go index 801ddd36fc..69ff231ccc 100644 --- a/internal/idp/providers/saml/saml_test.go +++ b/internal/idp/providers/saml/saml_test.go @@ -3,6 +3,7 @@ package saml import ( "encoding/xml" "testing" + "time" "github.com/crewjam/saml" "github.com/crewjam/saml/samlsp" @@ -271,6 +272,31 @@ func TestParseMetadata(t *testing.T) { }, nil, }, + { + "valid entities using xsd duration descriptor", + args{ + metadata: []byte(``), + }, + &saml.EntityDescriptor{ + EntityID: "http://localhost:8000/metadata", + CacheDuration: 5 * time.Hour, + IDPSSODescriptors: []saml.IDPSSODescriptor{ + { + XMLName: xml.Name{ + Space: "urn:oasis:names:tc:SAML:2.0:metadata", + Local: "IDPSSODescriptor", + }, + SingleSignOnServices: []saml.Endpoint{ + { + Binding: saml.HTTPRedirectBinding, + Location: "http://localhost:8000/sso", + }, + }, + }, + }, + }, + nil, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/integration/client.go b/internal/integration/client.go index 34d4302ef4..af30f0e642 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -271,7 +271,7 @@ func (i *Instance) CreateOrganizationWithUserID(ctx context.Context, name, userI return resp } -func (i *Instance) CreateHumanUserVerified(ctx context.Context, org, email string) *user_v2.AddHumanUserResponse { +func (i *Instance) CreateHumanUserVerified(ctx context.Context, org, email, phone string) *user_v2.AddHumanUserResponse { resp, err := i.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ Organization: &object.Organization{ Org: &object.Organization_OrgId{ @@ -292,7 +292,7 @@ func (i *Instance) CreateHumanUserVerified(ctx context.Context, org, email strin }, }, Phone: &user_v2.SetHumanPhone{ - Phone: "+41791234567", + Phone: phone, Verification: &user_v2.SetHumanPhone_IsVerified{ IsVerified: true, }, @@ -327,7 +327,7 @@ func (i *Instance) CreateUserIDPlink(ctx context.Context, userID, externalID, id ) } -func (i *Instance) RegisterUserPasskey(ctx context.Context, userID string) { +func (i *Instance) RegisterUserPasskey(ctx context.Context, userID string) string { reg, err := i.Client.UserV2.CreatePasskeyRegistrationLink(ctx, &user_v2.CreatePasskeyRegistrationLinkRequest{ UserId: userID, Medium: &user_v2.CreatePasskeyRegistrationLinkRequest_ReturnCode{}, @@ -350,9 +350,10 @@ func (i *Instance) RegisterUserPasskey(ctx context.Context, userID string) { PasskeyName: "nice name", }) logging.OnError(err).Panic("create user passkey") + return pkr.GetPasskeyId() } -func (i *Instance) RegisterUserU2F(ctx context.Context, userID string) { +func (i *Instance) RegisterUserU2F(ctx context.Context, userID string) string { pkr, err := i.Client.UserV2.RegisterU2F(ctx, &user_v2.RegisterU2FRequest{ UserId: userID, Domain: i.Domain, @@ -368,6 +369,21 @@ func (i *Instance) RegisterUserU2F(ctx context.Context, userID string) { TokenName: "nice name", }) logging.OnError(err).Panic("create user u2f") + return pkr.GetU2FId() +} + +func (i *Instance) RegisterUserOTPSMS(ctx context.Context, userID string) { + _, err := i.Client.UserV2.AddOTPSMS(ctx, &user_v2.AddOTPSMSRequest{ + UserId: userID, + }) + logging.OnError(err).Panic("create user sms") +} + +func (i *Instance) RegisterUserOTPEmail(ctx context.Context, userID string) { + _, err := i.Client.UserV2.AddOTPEmail(ctx, &user_v2.AddOTPEmailRequest{ + UserId: userID, + }) + logging.OnError(err).Panic("create user email") } func (i *Instance) SetUserPassword(ctx context.Context, userID, password string, changeRequired bool) *object.Details { diff --git a/internal/query/user_auth_method.go b/internal/query/user_auth_method.go index 3ba794ee0f..0687545aef 100644 --- a/internal/query/user_auth_method.go +++ b/internal/query/user_auth_method.go @@ -270,6 +270,14 @@ func NewUserAuthMethodTypesSearchQuery(values ...domain.UserAuthMethodType) (Sea return NewListQuery(UserAuthMethodColumnMethodType, list, ListIn) } +func NewUserAuthMethodStatesSearchQuery(values ...domain.MFAState) (SearchQuery, error) { + list := make([]interface{}, len(values)) + for i, value := range values { + list[i] = value + } + return NewListQuery(UserAuthMethodColumnState, list, ListIn) +} + func (r *UserAuthMethodSearchQueries) AppendResourceOwnerQuery(orgID string) error { query, err := NewUserAuthMethodResourceOwnerSearchQuery(orgID) if err != nil { @@ -306,6 +314,15 @@ func (r *UserAuthMethodSearchQueries) AppendStateQuery(state domain.MFAState) er return nil } +func (r *UserAuthMethodSearchQueries) AppendStatesQuery(state ...domain.MFAState) error { + query, err := NewUserAuthMethodStatesSearchQuery(state...) + if err != nil { + return err + } + r.Queries = append(r.Queries, query) + return nil +} + func (r *UserAuthMethodSearchQueries) AppendAuthMethodQuery(authMethod domain.UserAuthMethodType) error { query, err := NewUserAuthMethodTypeSearchQuery(authMethod) if err != nil { diff --git a/proto/zitadel/user/v2/query.proto b/proto/zitadel/user/v2/query.proto index 53f3446bca..71bb6dc594 100644 --- a/proto/zitadel/user/v2/query.proto +++ b/proto/zitadel/user/v2/query.proto @@ -30,6 +30,7 @@ message SearchQuery { NotQuery not_query = 13; InUserEmailsQuery in_user_emails_query = 14; OrganizationIdQuery organization_id_query = 15; + PhoneQuery phone_query = 16; } } @@ -184,6 +185,26 @@ message EmailQuery { ]; } +// Query for users with a specific phone. +message PhoneQuery { + string number = 1 [ + (validate.rules).string = {max_len: 20}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Phone number of the user" + min_length: 1; + max_length: 20; + example: "\"+41791234567\""; + } + ]; + zitadel.object.v2.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} + // Query for users with a specific state. message LoginNameQuery { string login_name = 1 [ diff --git a/proto/zitadel/user/v2/user.proto b/proto/zitadel/user/v2/user.proto index cfeebbf33d..b569b81bbd 100644 --- a/proto/zitadel/user/v2/user.proto +++ b/proto/zitadel/user/v2/user.proto @@ -276,6 +276,36 @@ message Passkey { ]; } +message AuthFactor { + AuthFactorState state = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "current state of the auth factor"; + } + ]; + oneof type { + AuthFactorOTP otp = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "TOTP second factor" + } + ]; + AuthFactorU2F u2f = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "U2F second factor" + } + ]; + AuthFactorOTPSMS otp_sms = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "SMS second factor" + } + ]; + AuthFactorOTPEmail otp_email = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Email second factor" + } + ]; + } +} + enum AuthFactorState { AUTH_FACTOR_STATE_UNSPECIFIED = 0; AUTH_FACTOR_STATE_NOT_READY = 1; @@ -283,6 +313,23 @@ enum AuthFactorState { AUTH_FACTOR_STATE_REMOVED = 3; } +message AuthFactorOTP {} +message AuthFactorOTPSMS {} +message AuthFactorOTPEmail {} + +message AuthFactorU2F { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; + string name = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"fido key\"" + } + ]; +} + message SendInviteCode { // Optionally set a url_template, which will be used in the invite mail sent by ZITADEL to guide the user to your invitation page. // If no template is set, the default ZITADEL url will be used. diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 83b025bf0a..7e5b8a02e8 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" @@ -1085,6 +1110,28 @@ service UserService { }; } + rpc ListAuthenticationFactors(ListAuthenticationFactorsRequest) returns (ListAuthenticationFactorsResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/authentication_factors/_search" + }; + + 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"; + } + }; + }; + + } + // Create an invite code for a user // // Create an invite code for a user to initialize their first authentication method (password, passkeys, IdP) depending on the organization's available methods. @@ -1310,6 +1357,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}, @@ -2168,6 +2238,41 @@ enum AuthenticationMethodType { AUTHENTICATION_METHOD_TYPE_OTP_EMAIL = 7; } +message ListAuthenticationFactorsRequest{ + 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\""; + } + ]; + repeated AuthFactors auth_factors = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the Auth Factors you are interested in" + default: "All Auth Factors" + } + ]; + repeated AuthFactorState states = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the state of the Auth Factors" + default: "Auth Factors that are ready" + } + ]; +} + +enum AuthFactors { + OTP = 0; + OTP_SMS = 1; + OTP_EMAIL = 2; + U2F = 3; +} + +message ListAuthenticationFactorsResponse { + repeated zitadel.user.v2.AuthFactor result = 1; +} + message CreateInviteCodeRequest { string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, diff --git a/proto/zitadel/user/v2beta/query.proto b/proto/zitadel/user/v2beta/query.proto index e339cdde71..caf02df747 100644 --- a/proto/zitadel/user/v2beta/query.proto +++ b/proto/zitadel/user/v2beta/query.proto @@ -30,6 +30,7 @@ message SearchQuery { NotQuery not_query = 13; InUserEmailsQuery in_user_emails_query = 14; OrganizationIdQuery organization_id_query = 15; + PhoneQuery phone_query = 16; } } @@ -184,6 +185,26 @@ message EmailQuery { ]; } +// Query for users with a specific phone. +message PhoneQuery { + string number = 1 [ + (validate.rules).string = {max_len: 20}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Phone number of the user" + min_length: 1; + max_length: 20; + example: "\"+41791234567\""; + } + ]; + zitadel.object.v2beta.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} + // Query for users with a specific state. message LoginNameQuery { string login_name = 1 [