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 [