diff --git a/internal/api/authz/user.go b/internal/api/authz/user.go index 13dc4076f..3bfbe45ce 100644 --- a/internal/api/authz/user.go +++ b/internal/api/authz/user.go @@ -10,7 +10,7 @@ import ( // equals the authenticated user in the context. func UserIDInCTX(ctx context.Context, userID string) error { if GetCtxData(ctx).UserID != userID { - return errors.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong") + return errors.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong") } return nil } diff --git a/internal/api/grpc/admin/import.go b/internal/api/grpc/admin/import.go index f905af159..b5efbb781 100644 --- a/internal/api/grpc/admin/import.go +++ b/internal/api/grpc/admin/import.go @@ -552,7 +552,7 @@ func (s *Server) importData(ctx context.Context, orgs []*admin_pb.DataOrg) (*adm if user.User.OtpCode != "" { logging.Debugf("import user otp: %s", user.GetUserId()) - if err := s.command.ImportHumanOTP(ctx, user.UserId, "", org.GetOrgId(), user.User.OtpCode); err != nil { + if err := s.command.ImportHumanTOTP(ctx, user.UserId, "", org.GetOrgId(), user.User.OtpCode); err != nil { errors = append(errors, &admin_pb.ImportDataError{Type: "human_user_otp", Id: user.GetUserId(), Message: err.Error()}) if isCtxTimeout(ctx) { return &admin_pb.ImportDataResponse{Errors: errors, Success: success}, count, err diff --git a/internal/api/grpc/auth/multi_factor.go b/internal/api/grpc/auth/multi_factor.go index cf9564449..b7c23da1a 100644 --- a/internal/api/grpc/auth/multi_factor.go +++ b/internal/api/grpc/auth/multi_factor.go @@ -18,7 +18,7 @@ func (s *Server) ListMyAuthFactors(ctx context.Context, _ *auth_pb.ListMyAuthFac if err != nil { return nil, err } - err = query.AppendAuthMethodsQuery(domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeOTP) + err = query.AppendAuthMethodsQuery(domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPSMS, domain.UserAuthMethodTypeOTPEmail) if err != nil { return nil, err } @@ -37,16 +37,16 @@ func (s *Server) ListMyAuthFactors(ctx context.Context, _ *auth_pb.ListMyAuthFac func (s *Server) AddMyAuthFactorOTP(ctx context.Context, _ *auth_pb.AddMyAuthFactorOTPRequest) (*auth_pb.AddMyAuthFactorOTPResponse, error) { ctxData := authz.GetCtxData(ctx) - otp, err := s.command.AddHumanOTP(ctx, ctxData.UserID, ctxData.ResourceOwner) + otp, err := s.command.AddHumanTOTP(ctx, ctxData.UserID, ctxData.ResourceOwner) if err != nil { return nil, err } return &auth_pb.AddMyAuthFactorOTPResponse{ - Url: otp.Url, - Secret: otp.SecretString, + Url: otp.URI, + Secret: otp.Secret, Details: object.AddToDetailsPb( otp.Sequence, - otp.ChangeDate, + otp.EventDate, otp.ResourceOwner, ), }, nil @@ -54,7 +54,7 @@ func (s *Server) AddMyAuthFactorOTP(ctx context.Context, _ *auth_pb.AddMyAuthFac func (s *Server) VerifyMyAuthFactorOTP(ctx context.Context, req *auth_pb.VerifyMyAuthFactorOTPRequest) (*auth_pb.VerifyMyAuthFactorOTPResponse, error) { ctxData := authz.GetCtxData(ctx) - objectDetails, err := s.command.HumanCheckMFAOTPSetup(ctx, ctxData.UserID, req.Code, "", ctxData.ResourceOwner) + objectDetails, err := s.command.HumanCheckMFATOTPSetup(ctx, ctxData.UserID, req.Code, "", ctxData.ResourceOwner) if err != nil { return nil, err } @@ -65,7 +65,7 @@ func (s *Server) VerifyMyAuthFactorOTP(ctx context.Context, req *auth_pb.VerifyM func (s *Server) RemoveMyAuthFactorOTP(ctx context.Context, _ *auth_pb.RemoveMyAuthFactorOTPRequest) (*auth_pb.RemoveMyAuthFactorOTPResponse, error) { ctxData := authz.GetCtxData(ctx) - objectDetails, err := s.command.HumanRemoveOTP(ctx, ctxData.UserID, ctxData.ResourceOwner) + objectDetails, err := s.command.HumanRemoveTOTP(ctx, ctxData.UserID, ctxData.ResourceOwner) if err != nil { return nil, err } @@ -74,6 +74,50 @@ func (s *Server) RemoveMyAuthFactorOTP(ctx context.Context, _ *auth_pb.RemoveMyA }, nil } +func (s *Server) AddMyAuthFactorOTPSMS(ctx context.Context, _ *auth_pb.AddMyAuthFactorOTPSMSRequest) (*auth_pb.AddMyAuthFactorOTPSMSResponse, error) { + ctxData := authz.GetCtxData(ctx) + details, err := s.command.AddHumanOTPSMS(ctx, ctxData.UserID, ctxData.ResourceOwner) + if err != nil { + return nil, err + } + return &auth_pb.AddMyAuthFactorOTPSMSResponse{ + Details: object.DomainToAddDetailsPb(details), + }, nil +} + +func (s *Server) RemoveMyAuthFactorOTPSMS(ctx context.Context, _ *auth_pb.RemoveMyAuthFactorOTPSMSRequest) (*auth_pb.RemoveMyAuthFactorOTPSMSResponse, error) { + ctxData := authz.GetCtxData(ctx) + details, err := s.command.RemoveHumanOTPSMS(ctx, ctxData.UserID, ctxData.ResourceOwner) + if err != nil { + return nil, err + } + return &auth_pb.RemoveMyAuthFactorOTPSMSResponse{ + Details: object.DomainToChangeDetailsPb(details), + }, nil +} + +func (s *Server) AddMyAuthFactorOTPEmail(ctx context.Context, _ *auth_pb.AddMyAuthFactorOTPEmailRequest) (*auth_pb.AddMyAuthFactorOTPEmailResponse, error) { + ctxData := authz.GetCtxData(ctx) + details, err := s.command.AddHumanOTPEmail(ctx, ctxData.UserID, ctxData.ResourceOwner) + if err != nil { + return nil, err + } + return &auth_pb.AddMyAuthFactorOTPEmailResponse{ + Details: object.DomainToAddDetailsPb(details), + }, nil +} + +func (s *Server) RemoveMyAuthFactorOTPEmail(ctx context.Context, _ *auth_pb.RemoveMyAuthFactorOTPEmailRequest) (*auth_pb.RemoveMyAuthFactorOTPEmailResponse, error) { + ctxData := authz.GetCtxData(ctx) + details, err := s.command.RemoveHumanOTPEmail(ctx, ctxData.UserID, ctxData.ResourceOwner) + if err != nil { + return nil, err + } + return &auth_pb.RemoveMyAuthFactorOTPEmailResponse{ + Details: object.DomainToChangeDetailsPb(details), + }, nil +} + func (s *Server) AddMyAuthFactorU2F(ctx context.Context, _ *auth_pb.AddMyAuthFactorU2FRequest) (*auth_pb.AddMyAuthFactorU2FResponse, error) { ctxData := authz.GetCtxData(ctx) u2f, err := s.command.HumanAddU2FSetup(ctx, ctxData.UserID, ctxData.ResourceOwner, false) diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index d7208c5a4..c37ce3728 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -613,7 +613,7 @@ func (s *Server) ListHumanAuthFactors(ctx context.Context, req *mgmt_pb.ListHuma if err != nil { return nil, err } - err = query.AppendAuthMethodsQuery(domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeOTP) + err = query.AppendAuthMethodsQuery(domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPSMS, domain.UserAuthMethodTypeOTPEmail) if err != nil { return nil, err } @@ -631,7 +631,7 @@ func (s *Server) ListHumanAuthFactors(ctx context.Context, req *mgmt_pb.ListHuma } func (s *Server) RemoveHumanAuthFactorOTP(ctx context.Context, req *mgmt_pb.RemoveHumanAuthFactorOTPRequest) (*mgmt_pb.RemoveHumanAuthFactorOTPResponse, error) { - objectDetails, err := s.command.HumanRemoveOTP(ctx, req.UserId, authz.GetCtxData(ctx).OrgID) + objectDetails, err := s.command.HumanRemoveTOTP(ctx, req.UserId, authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } diff --git a/internal/api/grpc/session/v2/session_integration_test.go b/internal/api/grpc/session/v2/session_integration_test.go index 949d78ded..7f6ad9664 100644 --- a/internal/api/grpc/session/v2/session_integration_test.go +++ b/internal/api/grpc/session/v2/session_integration_test.go @@ -396,7 +396,7 @@ func Test_ZITADEL_API_missing_authentication(t *testing.T) { func Test_ZITADEL_API_missing_mfa(t *testing.T) { id, token, _, _ := Tester.CreatePasswordSession(t, CTX, User.GetUserId(), integration.UserPassword) - ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token)) + ctx := Tester.WithAuthorizationToken(context.Background(), token) sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) require.Error(t, err) require.Nil(t, sessionResp) @@ -405,7 +405,7 @@ func Test_ZITADEL_API_missing_mfa(t *testing.T) { func Test_ZITADEL_API_success(t *testing.T) { id, token, _, _ := Tester.CreatePasskeySession(t, CTX, User.GetUserId()) - ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token)) + ctx := Tester.WithAuthorizationToken(context.Background(), token) sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) require.NoError(t, err) require.NotNil(t, id, sessionResp.GetSession().GetFactors().GetPasskey().GetVerifiedAt().AsTime()) @@ -415,7 +415,7 @@ func Test_ZITADEL_API_session_not_found(t *testing.T) { id, token, _, _ := Tester.CreatePasskeySession(t, CTX, User.GetUserId()) // test session token works - ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token)) + ctx := Tester.WithAuthorizationToken(context.Background(), token) _, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) require.NoError(t, err) @@ -425,7 +425,7 @@ func Test_ZITADEL_API_session_not_found(t *testing.T) { SessionToken: gu.Ptr(token), }) require.NoError(t, err) - ctx = metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token)) + ctx = Tester.WithAuthorizationToken(context.Background(), token) _, err = Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) require.Error(t, err) } diff --git a/internal/api/grpc/user/converter.go b/internal/api/grpc/user/converter.go index 6b56ad663..7b00d2f4c 100644 --- a/internal/api/grpc/user/converter.go +++ b/internal/api/grpc/user/converter.go @@ -197,7 +197,7 @@ func AuthMethodToPb(mfa *query.AuthMethod) *user_pb.AuthFactor { State: MFAStateToPb(mfa.State), } switch mfa.Type { - case domain.UserAuthMethodTypeOTP: + case domain.UserAuthMethodTypeTOTP: factor.Type = &user_pb.AuthFactor_Otp{ Otp: &user_pb.AuthFactorOTP{}, } @@ -208,6 +208,14 @@ func AuthMethodToPb(mfa *query.AuthMethod) *user_pb.AuthFactor { 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{}, + } } return factor } diff --git a/internal/api/grpc/user/v2/otp.go b/internal/api/grpc/user/v2/otp.go new file mode 100644 index 000000000..6e8e7686c --- /dev/null +++ b/internal/api/grpc/user/v2/otp.go @@ -0,0 +1,43 @@ +package user + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" +) + +func (s *Server) AddOTPSMS(ctx context.Context, req *user.AddOTPSMSRequest) (*user.AddOTPSMSResponse, error) { + details, err := s.command.AddHumanOTPSMS(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner) + if err != nil { + return nil, err + } + return &user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}, nil + +} + +func (s *Server) RemoveOTPSMS(ctx context.Context, req *user.RemoveOTPSMSRequest) (*user.RemoveOTPSMSResponse, error) { + objectDetails, err := s.command.RemoveHumanOTPSMS(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner) + if err != nil { + return nil, err + } + return &user.RemoveOTPSMSResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil +} + +func (s *Server) AddOTPEmail(ctx context.Context, req *user.AddOTPEmailRequest) (*user.AddOTPEmailResponse, error) { + details, err := s.command.AddHumanOTPEmail(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner) + if err != nil { + return nil, err + } + return &user.AddOTPEmailResponse{Details: object.DomainToDetailsPb(details)}, nil + +} + +func (s *Server) RemoveOTPEmail(ctx context.Context, req *user.RemoveOTPEmailRequest) (*user.RemoveOTPEmailResponse, error) { + objectDetails, err := s.command.RemoveHumanOTPEmail(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner) + if err != nil { + return nil, err + } + return &user.RemoveOTPEmailResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil +} diff --git a/internal/api/grpc/user/v2/otp_integration_test.go b/internal/api/grpc/user/v2/otp_integration_test.go new file mode 100644 index 000000000..5d36dd361 --- /dev/null +++ b/internal/api/grpc/user/v2/otp_integration_test.go @@ -0,0 +1,302 @@ +//go:build integration + +package user_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" +) + +func TestServer_AddOTPSMS(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID) + + // TODO: add when phone can be added to user + /* + userIDPhone := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userIDPhone) + _, sessionTokenPhone, _, _ := Tester.CreatePasskeySession(t, CTX, userIDPhone) + */ + type args struct { + ctx context.Context + req *user.AddOTPSMSRequest + } + tests := []struct { + name string + args args + want *user.AddOTPSMSResponse + wantErr bool + }{ + { + name: "missing user id", + args: args{ + ctx: CTX, + req: &user.AddOTPSMSRequest{}, + }, + wantErr: true, + }, + { + name: "user mismatch", + args: args{ + ctx: CTX, + req: &user.AddOTPSMSRequest{ + UserId: "wrong", + }, + }, + wantErr: true, + }, + { + name: "phone not verified", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + req: &user.AddOTPSMSRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + // TODO: add when phone can be added to user + /* + { + name: "add success", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenPhone), + req: &user.AddOTPSMSRequest{ + UserId: userID, + }, + }, + want: &user.AddOTPSMSResponse{ + Details: &object.Details{ + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + */ + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.AddOTPSMS(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_RemoveOTPSMS(t *testing.T) { + // TODO: add when phone can be added to user + /* + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID) + */ + + type args struct { + ctx context.Context + req *user.RemoveOTPSMSRequest + } + tests := []struct { + name string + args args + want *user.RemoveOTPSMSResponse + wantErr bool + }{ + { + name: "not added", + args: args{ + ctx: CTX, + req: &user.RemoveOTPSMSRequest{ + UserId: "wrong", + }, + }, + wantErr: true, + }, + // TODO: add when phone can be added to user + /* + { + name: "success", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + req: &user.RemoveOTPSMSRequest{ + UserId: userID, + }, + }, + want: &user.RemoveOTPSMSResponse{ + Details: &object.Details{ + ResourceOwner: Tester.Organisation.ResourceOwner, + }, + }, + }, + */ + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.RemoveOTPSMS(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_AddOTPEmail(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID) + + userVerified := Tester.CreateHumanUser(CTX) + _, err := Tester.Client.UserV2.VerifyEmail(CTX, &user.VerifyEmailRequest{ + UserId: userVerified.GetUserId(), + VerificationCode: userVerified.GetEmailCode(), + }) + require.NoError(t, err) + Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Tester.CreatePasskeySession(t, CTX, userVerified.GetUserId()) + + type args struct { + ctx context.Context + req *user.AddOTPEmailRequest + } + tests := []struct { + name string + args args + want *user.AddOTPEmailResponse + wantErr bool + }{ + { + name: "missing user id", + args: args{ + ctx: CTX, + req: &user.AddOTPEmailRequest{}, + }, + wantErr: true, + }, + { + name: "user mismatch", + args: args{ + ctx: CTX, + req: &user.AddOTPEmailRequest{ + UserId: "wrong", + }, + }, + wantErr: true, + }, + { + name: "email not verified", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + req: &user.AddOTPEmailRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + { + name: "add success", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified), + req: &user.AddOTPEmailRequest{ + UserId: userVerified.GetUserId(), + }, + }, + want: &user.AddOTPEmailResponse{ + Details: &object.Details{ + ResourceOwner: Tester.Organisation.ID, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.AddOTPEmail(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} + +func TestServer_RemoveOTPEmail(t *testing.T) { + userID := Tester.CreateHumanUser(CTX).GetUserId() + Tester.RegisterUserPasskey(CTX, userID) + _, sessionToken, _, _ := Tester.CreatePasskeySession(t, CTX, userID) + + userVerified := Tester.CreateHumanUser(CTX) + Tester.RegisterUserPasskey(CTX, userVerified.GetUserId()) + _, sessionTokenVerified, _, _ := Tester.CreatePasskeySession(t, CTX, userVerified.GetUserId()) + userVerifiedCtx := Tester.WithAuthorizationToken(context.Background(), sessionTokenVerified) + _, err := Tester.Client.UserV2.VerifyEmail(userVerifiedCtx, &user.VerifyEmailRequest{ + UserId: userVerified.GetUserId(), + VerificationCode: userVerified.GetEmailCode(), + }) + require.NoError(t, err) + _, err = Tester.Client.UserV2.AddOTPEmail(userVerifiedCtx, &user.AddOTPEmailRequest{UserId: userVerified.GetUserId()}) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.RemoveOTPEmailRequest + } + tests := []struct { + name string + args args + want *user.RemoveOTPEmailResponse + wantErr bool + }{ + { + name: "not added", + args: args{ + ctx: Tester.WithAuthorizationToken(context.Background(), sessionToken), + req: &user.RemoveOTPEmailRequest{ + UserId: userID, + }, + }, + wantErr: true, + }, + { + name: "success", + args: args{ + ctx: userVerifiedCtx, + req: &user.RemoveOTPEmailRequest{ + UserId: userVerified.GetUserId(), + }, + }, + want: &user.RemoveOTPEmailResponse{ + Details: &object.Details{ + ResourceOwner: Tester.Organisation.ResourceOwner, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.RemoveOTPEmail(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, got) + integration.AssertDetails(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index b115d83eb..b0b6f439e 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -213,7 +213,7 @@ func authMethodTypesToPb(methodTypes []domain.UserAuthMethodType) []user.Authent func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.AuthenticationMethodType { switch methodType { - case domain.UserAuthMethodTypeOTP: + case domain.UserAuthMethodTypeTOTP: return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_TOTP case domain.UserAuthMethodTypeU2F: return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_U2F @@ -223,6 +223,10 @@ func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.Authenticatio return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_PASSWORD case domain.UserAuthMethodTypeIDP: return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP + case domain.UserAuthMethodTypeOTPSMS: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_SMS + case domain.UserAuthMethodTypeOTPEmail: + return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_EMAIL case domain.UserAuthMethodTypeUnspecified: return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED default: diff --git a/internal/api/grpc/user/v2/user_test.go b/internal/api/grpc/user/v2/user_test.go index fa3ce0b0d..e540ae7f1 100644 --- a/internal/api/grpc/user/v2/user_test.go +++ b/internal/api/grpc/user/v2/user_test.go @@ -194,8 +194,8 @@ func Test_authMethodTypeToPb(t *testing.T) { user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED, }, { - "(t)otp", - domain.UserAuthMethodTypeOTP, + "totp", + domain.UserAuthMethodTypeTOTP, user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_TOTP, }, { @@ -218,6 +218,16 @@ func Test_authMethodTypeToPb(t *testing.T) { domain.UserAuthMethodTypeIDP, user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_IDP, }, + { + "otp sms", + domain.UserAuthMethodTypeOTPSMS, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_SMS, + }, + { + "otp email", + domain.UserAuthMethodTypeOTPEmail, + user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_EMAIL, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/api/oidc/amr.go b/internal/api/oidc/amr.go index 727e0f888..e53db8b7c 100644 --- a/internal/api/oidc/amr.go +++ b/internal/api/oidc/amr.go @@ -22,25 +22,35 @@ const ( // [RFC 8176, section 2]: https://datatracker.ietf.org/doc/html/rfc8176#section-2 func AuthMethodTypesToAMR(methodTypes []domain.UserAuthMethodType) []string { amr := make([]string, 0, 4) - var mfa bool + var factors, otp int for _, methodType := range methodTypes { switch methodType { case domain.UserAuthMethodTypePassword: amr = append(amr, PWD) + factors++ case domain.UserAuthMethodTypePasswordless: - mfa = true amr = append(amr, UserPresence) + factors += 2 case domain.UserAuthMethodTypeU2F: amr = append(amr, UserPresence) - case domain.UserAuthMethodTypeOTP: - amr = append(amr, OTP) + factors++ + case domain.UserAuthMethodTypeTOTP, + domain.UserAuthMethodTypeOTPSMS, + domain.UserAuthMethodTypeOTPEmail: + // a user could use multiple (t)otp, which is a factor, but still will be returned as a single `otp` entry + otp++ + factors++ case domain.UserAuthMethodTypeIDP: // no AMR value according to specification + factors++ case domain.UserAuthMethodTypeUnspecified: // ignore } } - if mfa || len(amr) >= 2 { + if otp > 0 { + amr = append(amr, OTP) + } + if factors >= 2 { amr = append(amr, MFA) } return amr diff --git a/internal/api/oidc/amr_test.go b/internal/api/oidc/amr_test.go index 1861085bc..d4468022f 100644 --- a/internal/api/oidc/amr_test.go +++ b/internal/api/oidc/amr_test.go @@ -46,12 +46,33 @@ func TestAMR(t *testing.T) { []string{UserPresence}, }, { - "otp checked", + "totp checked", args{ - []domain.UserAuthMethodType{domain.UserAuthMethodTypeOTP}, + []domain.UserAuthMethodType{domain.UserAuthMethodTypeTOTP}, }, []string{OTP}, }, + { + "otp sms checked", + args{ + []domain.UserAuthMethodType{domain.UserAuthMethodTypeOTPSMS}, + }, + []string{OTP}, + }, + { + "otp email checked", + args{ + []domain.UserAuthMethodType{domain.UserAuthMethodTypeOTPEmail}, + }, + []string{OTP}, + }, + { + "multiple (t)otp checked", + args{ + []domain.UserAuthMethodType{domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPEmail}, + }, + []string{OTP, MFA}, + }, { "multiple checked", args{ diff --git a/internal/api/oidc/auth_request_converter.go b/internal/api/oidc/auth_request_converter.go index 02cbf8cba..d424abfc5 100644 --- a/internal/api/oidc/auth_request_converter.go +++ b/internal/api/oidc/auth_request_converter.go @@ -261,7 +261,7 @@ func CodeChallengeToOIDC(challenge *domain.OIDCCodeChallenge) *oidc.CodeChalleng func AMRFromMFAType(mfaType domain.MFAType) string { switch mfaType { - case domain.MFATypeOTP: + case domain.MFATypeTOTP: return OTP case domain.MFATypeU2F, domain.MFATypeU2FUserVerification: diff --git a/internal/api/ui/login/mfa_init_verify_handler.go b/internal/api/ui/login/mfa_init_verify_handler.go index 5993e2ae7..e6f0749e9 100644 --- a/internal/api/ui/login/mfa_init_verify_handler.go +++ b/internal/api/ui/login/mfa_init_verify_handler.go @@ -33,7 +33,7 @@ func (l *Login) handleMFAInitVerify(w http.ResponseWriter, r *http.Request) { } var verifyData *mfaVerifyData switch data.MFAType { - case domain.MFATypeOTP: + case domain.MFATypeTOTP: verifyData = l.handleOTPVerify(w, r, authReq, data) } @@ -50,13 +50,13 @@ func (l *Login) handleMFAInitVerify(w http.ResponseWriter, r *http.Request) { func (l *Login) handleOTPVerify(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *mfaInitVerifyData) *mfaVerifyData { userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) - _, err := l.command.HumanCheckMFAOTPSetup(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, data.Code, userAgentID, authReq.UserOrgID) + _, err := l.command.HumanCheckMFATOTPSetup(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, data.Code, userAgentID, authReq.UserOrgID) if err == nil { return nil } mfadata := &mfaVerifyData{ MFAType: data.MFAType, - otpData: otpData{ + totpData: totpData{ Secret: data.Secret, Url: data.URL, }, @@ -73,10 +73,10 @@ func (l *Login) renderMFAInitVerify(w http.ResponseWriter, r *http.Request, auth translator := l.getTranslator(r.Context(), authReq) data.baseData = l.getBaseData(r, authReq, "InitMFAOTP.Title", "InitMFAOTP.Description", errID, errMessage) data.profileData = l.getProfileData(authReq) - if data.MFAType == domain.MFATypeOTP { - code, err := generateQrCode(data.otpData.Url) + if data.MFAType == domain.MFATypeTOTP { + code, err := generateQrCode(data.totpData.Url) if err == nil { - data.otpData.QrCode = code + data.totpData.QrCode = code } } diff --git a/internal/api/ui/login/mfa_prompt_handler.go b/internal/api/ui/login/mfa_prompt_handler.go index 185d61be9..c18511d31 100644 --- a/internal/api/ui/login/mfa_prompt_handler.go +++ b/internal/api/ui/login/mfa_prompt_handler.go @@ -80,8 +80,8 @@ func (l *Login) renderMFAPrompt(w http.ResponseWriter, r *http.Request, authReq func (l *Login) handleMFACreation(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *mfaVerifyData) { switch data.MFAType { - case domain.MFATypeOTP: - l.handleOTPCreation(w, r, authReq, data) + case domain.MFATypeTOTP: + l.handleTOTPCreation(w, r, authReq, data) return case domain.MFATypeU2F: l.renderRegisterU2F(w, r, authReq, nil) @@ -90,16 +90,16 @@ func (l *Login) handleMFACreation(w http.ResponseWriter, r *http.Request, authRe l.renderError(w, r, authReq, caos_errs.ThrowPreconditionFailed(nil, "APP-Or3HO", "Errors.User.MFA.NoProviders")) } -func (l *Login) handleOTPCreation(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *mfaVerifyData) { - otp, err := l.command.AddHumanOTP(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, authReq.UserOrgID) +func (l *Login) handleTOTPCreation(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, data *mfaVerifyData) { + otp, err := l.command.AddHumanTOTP(setContext(r.Context(), authReq.UserOrgID), authReq.UserID, authReq.UserOrgID) if err != nil { l.renderError(w, r, authReq, err) return } - data.otpData = otpData{ - Secret: otp.SecretString, - Url: otp.Url, + data.totpData = totpData{ + Secret: otp.Secret, + Url: otp.URI, } l.renderMFAInitVerify(w, r, authReq, data, nil) } diff --git a/internal/api/ui/login/mfa_verify_handler.go b/internal/api/ui/login/mfa_verify_handler.go index 788e189cb..b65809c89 100644 --- a/internal/api/ui/login/mfa_verify_handler.go +++ b/internal/api/ui/login/mfa_verify_handler.go @@ -33,7 +33,7 @@ func (l *Login) handleMFAVerify(w http.ResponseWriter, r *http.Request) { l.renderMFAVerifySelected(w, r, authReq, step, data.SelectedProvider, nil) return } - if data.MFAType == domain.MFATypeOTP { + if data.MFAType == domain.MFATypeTOTP { userAgentID, _ := http_mw.UserAgentIDFromCtx(r.Context()) err = l.authRepo.VerifyMFAOTP(setContext(r.Context(), authReq.UserOrgID), authReq.ID, authReq.UserID, authReq.UserOrgID, data.Code, userAgentID, domain.BrowserInfoFromRequest(r)) @@ -45,7 +45,7 @@ func (l *Login) handleMFAVerify(w http.ResponseWriter, r *http.Request) { } if err != nil { - l.renderMFAVerifySelected(w, r, authReq, step, domain.MFATypeOTP, err) + l.renderMFAVerifySelected(w, r, authReq, step, domain.MFATypeTOTP, err) return } } @@ -79,9 +79,9 @@ func (l *Login) renderMFAVerifySelected(w http.ResponseWriter, r *http.Request, data.Description = translator.LocalizeWithoutArgs("VerifyMFAU2F.Description") l.renderU2FVerification(w, r, authReq, removeSelectedProviderFromList(verificationStep.MFAProviders, domain.MFATypeU2F), nil) return - case domain.MFATypeOTP: - data.MFAProviders = removeSelectedProviderFromList(verificationStep.MFAProviders, domain.MFATypeOTP) - data.SelectedMFAProvider = domain.MFATypeOTP + case domain.MFATypeTOTP: + data.MFAProviders = removeSelectedProviderFromList(verificationStep.MFAProviders, domain.MFATypeTOTP) + data.SelectedMFAProvider = domain.MFATypeTOTP data.Title = translator.LocalizeWithoutArgs("VerifyMFAOTP.Title") data.Description = translator.LocalizeWithoutArgs("VerifyMFAOTP.Description") default: diff --git a/internal/api/ui/login/renderer.go b/internal/api/ui/login/renderer.go index 5c20168d3..bcbe4ad9a 100644 --- a/internal/api/ui/login/renderer.go +++ b/internal/api/ui/login/renderer.go @@ -673,7 +673,7 @@ type mfaVerifyData struct { baseData profileData MFAType domain.MFAType - otpData + totpData } type mfaDoneData struct { @@ -682,7 +682,7 @@ type mfaDoneData struct { MFAType domain.MFAType } -type otpData struct { +type totpData struct { Url string Secret string QrCode string diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index 9e797b8d0..26b138822 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -368,7 +368,7 @@ func (repo *AuthRequestRepo) VerifyMFAOTP(ctx context.Context, authRequestID, us if err != nil { return err } - return repo.Command.HumanCheckMFAOTP(ctx, userID, code, resourceOwner, request.WithCurrentInfo(info)) + return repo.Command.HumanCheckMFATOTP(ctx, userID, code, resourceOwner, request.WithCurrentInfo(info)) } func (repo *AuthRequestRepo) BeginMFAU2FLogin(ctx context.Context, userID, resourceOwner, authRequestID, userAgentID string) (login *domain.WebAuthNLogin, err error) { diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go index c94b57d7e..7e94c61ef 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go @@ -1016,7 +1016,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { }, }, false}, []domain.NextStep{&domain.MFAVerificationStep{ - MFAProviders: []domain.MFAType{domain.MFATypeOTP}, + MFAProviders: []domain.MFAType{domain.MFATypeTOTP}, }}, nil, }, @@ -1050,7 +1050,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { }, }, false}, []domain.NextStep{&domain.MFAVerificationStep{ - MFAProviders: []domain.MFAType{domain.MFATypeOTP}, + MFAProviders: []domain.MFAType{domain.MFATypeTOTP}, }}, nil, }, @@ -1087,7 +1087,7 @@ func TestAuthRequestRepo_nextSteps(t *testing.T) { }, }, false}, []domain.NextStep{&domain.MFAVerificationStep{ - MFAProviders: []domain.MFAType{domain.MFATypeOTP}, + MFAProviders: []domain.MFAType{domain.MFATypeTOTP}, }}, nil, }, @@ -1637,7 +1637,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { }, &domain.MFAPromptStep{ MFAProviders: []domain.MFAType{ - domain.MFATypeOTP, + domain.MFATypeTOTP, }, }, false, @@ -1663,7 +1663,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { &domain.MFAPromptStep{ Required: true, MFAProviders: []domain.MFAType{ - domain.MFATypeOTP, + domain.MFATypeTOTP, }, }, false, @@ -1731,7 +1731,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { }, &domain.MFAVerificationStep{ - MFAProviders: []domain.MFAType{domain.MFATypeOTP}, + MFAProviders: []domain.MFAType{domain.MFATypeTOTP}, }, false, nil, @@ -1755,7 +1755,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { isInternal: false, }, &domain.MFAVerificationStep{ - MFAProviders: []domain.MFAType{domain.MFATypeOTP}, + MFAProviders: []domain.MFAType{domain.MFATypeTOTP}, }, false, nil, @@ -1803,7 +1803,7 @@ func TestAuthRequestRepo_mfaChecked(t *testing.T) { &domain.MFAPromptStep{ Required: true, MFAProviders: []domain.MFAType{ - domain.MFATypeOTP, + domain.MFATypeTOTP, }, }, false, diff --git a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go index 5bcecf9dd..1e2153863 100644 --- a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go +++ b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go @@ -199,12 +199,21 @@ func authMethodsFromSession(session *query.Session) []domain.UserAuthMethodType // TODO: add checks with https://github.com/zitadel/zitadel/issues/5477 /* if !session.TOTPFactor.TOTPCheckedAt.IsZero() { - types = append(types, domain.UserAuthMethodTypeOTP) + types = append(types, domain.UserAuthMethodTypeTOTP) } if !session.U2FFactor.U2FCheckedAt.IsZero() { types = append(types, domain.UserAuthMethodTypeU2F) } */ + // TODO: add checks with https://github.com/zitadel/zitadel/issues/6224 + /* + if !session.TOTPFactor.OTPSMSCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypeOTPSMS) + } + if !session.TOTPFactor.OTPEmailCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypeOTPEmail) + } + */ return types } diff --git a/internal/command/session_model.go b/internal/command/session_model.go index dce787ff7..764724e0e 100644 --- a/internal/command/session_model.go +++ b/internal/command/session_model.go @@ -155,6 +155,7 @@ func (wm *SessionWriteModel) AuthenticationTime() time.Time { wm.PasskeyCheckedAt, wm.IntentCheckedAt, // TODO: add U2F and OTP check https://github.com/zitadel/zitadel/issues/5477 + // TODO: add OTP (sms and email) check https://github.com/zitadel/zitadel/issues/6224 } { if check.After(authTime) { authTime = check @@ -178,11 +179,20 @@ func (wm *SessionWriteModel) AuthMethodTypes() []domain.UserAuthMethodType { // TODO: add checks with https://github.com/zitadel/zitadel/issues/5477 /* if !wm.TOTPCheckedAt.IsZero() { - types = append(types, domain.UserAuthMethodTypeOTP) + types = append(types, domain.UserAuthMethodTypeTOTP) } if !wm.U2FCheckedAt.IsZero() { types = append(types, domain.UserAuthMethodTypeU2F) } */ + // TODO: add checks with https://github.com/zitadel/zitadel/issues/6224 + /* + if !wm.TOTPFactor.OTPSMSCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypeOTPSMS) + } + if !wm.TOTPFactor.OTPEmailCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypeOTPEmail) + } + */ return types } diff --git a/internal/command/user_human_otp.go b/internal/command/user_human_otp.go index 8bf6f0468..a1f207b40 100644 --- a/internal/command/user_human_otp.go +++ b/internal/command/user_human_otp.go @@ -11,12 +11,11 @@ import ( "github.com/zitadel/zitadel/internal/domain" caos_errs "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/telemetry/tracing" ) -func (c *Commands) ImportHumanOTP(ctx context.Context, userID, userAgentID, resourceowner string, key string) error { +func (c *Commands) ImportHumanTOTP(ctx context.Context, userID, userAgentID, resourceowner string, key string) error { encryptedSecret, err := crypto.Encrypt([]byte(key), c.multifactors.OTP.CryptoMFA) if err != nil { return err @@ -25,7 +24,7 @@ func (c *Commands) ImportHumanOTP(ctx context.Context, userID, userAgentID, reso return err } - otpWriteModel, err := c.otpWriteModelByID(ctx, userID, resourceowner) + otpWriteModel, err := c.totpWriteModelByID(ctx, userID, resourceowner) if err != nil { return err } @@ -41,7 +40,7 @@ func (c *Commands) ImportHumanOTP(ctx context.Context, userID, userAgentID, reso return err } -func (c *Commands) AddHumanOTP(ctx context.Context, userID, resourceowner string) (*domain.OTP, error) { +func (c *Commands) AddHumanTOTP(ctx context.Context, userID, resourceowner string) (*domain.TOTP, error) { if userID == "" { return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-5M0sd", "Errors.User.UserIDMissing") } @@ -49,21 +48,19 @@ func (c *Commands) AddHumanOTP(ctx context.Context, userID, resourceowner string if err != nil { return nil, err } - _, err = c.eventstore.Push(ctx, prep.cmds...) + err = c.pushAppendAndReduce(ctx, prep.wm, prep.cmds...) if err != nil { return nil, err } - return &domain.OTP{ - ObjectRoot: models.ObjectRoot{ - AggregateID: prep.userAgg.ID, - }, - SecretString: prep.key.Secret(), - Url: prep.key.URL(), + return &domain.TOTP{ + ObjectDetails: writeModelToObjectDetails(&prep.wm.WriteModel), + Secret: prep.key.Secret(), + URI: prep.key.URL(), }, nil } type preparedTOTP struct { - wm *HumanOTPWriteModel + wm *HumanTOTPWriteModel userAgg *eventstore.Aggregate key *otp.Key cmds []eventstore.Command @@ -72,21 +69,21 @@ type preparedTOTP struct { func (c *Commands) createHumanTOTP(ctx context.Context, userID, resourceOwner string) (*preparedTOTP, error) { human, err := c.getHuman(ctx, userID, resourceOwner) if err != nil { - logging.Log("COMMAND-DAqe1").WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get human for loginname") + logging.WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get human for loginname") return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-MM9fs", "Errors.User.NotFound") } org, err := c.getOrg(ctx, human.ResourceOwner) if err != nil { - logging.Log("COMMAND-Cm0ds").WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get org for loginname") + logging.WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get org for loginname") return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-55M9f", "Errors.Org.NotFound") } orgPolicy, err := c.getOrgDomainPolicy(ctx, org.AggregateID) if err != nil { - logging.Log("COMMAND-y5zv9").WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get org policy for loginname") + logging.WithError(err).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("unable to get org policy for loginname") return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-8ugTs", "Errors.Org.DomainPolicy.NotFound") } - otpWriteModel, err := c.otpWriteModelByID(ctx, userID, resourceOwner) + otpWriteModel, err := c.totpWriteModelByID(ctx, userID, resourceOwner) if err != nil { return nil, err } @@ -103,7 +100,7 @@ func (c *Commands) createHumanTOTP(ctx context.Context, userID, resourceOwner st if issuer == "" { issuer = authz.GetInstance(ctx).RequestedDomain() } - key, secret, err := domain.NewOTPKey(issuer, accountName, c.multifactors.OTP.CryptoMFA) + key, secret, err := domain.NewTOTPKey(issuer, accountName, c.multifactors.OTP.CryptoMFA) if err != nil { return nil, err } @@ -117,12 +114,12 @@ func (c *Commands) createHumanTOTP(ctx context.Context, userID, resourceOwner st }, nil } -func (c *Commands) HumanCheckMFAOTPSetup(ctx context.Context, userID, code, userAgentID, resourceowner string) (*domain.ObjectDetails, error) { +func (c *Commands) HumanCheckMFATOTPSetup(ctx context.Context, userID, code, userAgentID, resourceowner string) (*domain.ObjectDetails, error) { if userID == "" { return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-8N9ds", "Errors.User.UserIDMissing") } - existingOTP, err := c.otpWriteModelByID(ctx, userID, resourceowner) + existingOTP, err := c.totpWriteModelByID(ctx, userID, resourceowner) if err != nil { return nil, err } @@ -132,7 +129,7 @@ func (c *Commands) HumanCheckMFAOTPSetup(ctx context.Context, userID, code, user if existingOTP.State == domain.MFAStateReady { return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-qx4ls", "Errors.Users.MFA.OTP.AlreadyReady") } - if err := domain.VerifyMFAOTP(code, existingOTP.Secret, c.multifactors.OTP.CryptoMFA); err != nil { + if err := domain.VerifyTOTP(code, existingOTP.Secret, c.multifactors.OTP.CryptoMFA); err != nil { return nil, err } userAgg := UserAggregateFromWriteModel(&existingOTP.WriteModel) @@ -148,11 +145,11 @@ func (c *Commands) HumanCheckMFAOTPSetup(ctx context.Context, userID, code, user return writeModelToObjectDetails(&existingOTP.WriteModel), nil } -func (c *Commands) HumanCheckMFAOTP(ctx context.Context, userID, code, resourceowner string, authRequest *domain.AuthRequest) error { +func (c *Commands) HumanCheckMFATOTP(ctx context.Context, userID, code, resourceowner string, authRequest *domain.AuthRequest) error { if userID == "" { return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-8N9ds", "Errors.User.UserIDMissing") } - existingOTP, err := c.otpWriteModelByID(ctx, userID, resourceowner) + existingOTP, err := c.totpWriteModelByID(ctx, userID, resourceowner) if err != nil { return err } @@ -160,22 +157,22 @@ func (c *Commands) HumanCheckMFAOTP(ctx context.Context, userID, code, resourceo return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-3Mif9s", "Errors.User.MFA.OTP.NotReady") } userAgg := UserAggregateFromWriteModel(&existingOTP.WriteModel) - err = domain.VerifyMFAOTP(code, existingOTP.Secret, c.multifactors.OTP.CryptoMFA) + err = domain.VerifyTOTP(code, existingOTP.Secret, c.multifactors.OTP.CryptoMFA) if err == nil { _, err = c.eventstore.Push(ctx, user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest))) return err } _, pushErr := c.eventstore.Push(ctx, user.NewHumanOTPCheckFailedEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest))) - logging.Log("COMMAND-9fj7s").OnError(pushErr).Error("error create password check failed event") + logging.OnError(pushErr).Error("error create password check failed event") return err } -func (c *Commands) HumanRemoveOTP(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) { +func (c *Commands) HumanRemoveTOTP(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) { if userID == "" { return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-5M0sd", "Errors.User.UserIDMissing") } - existingOTP, err := c.otpWriteModelByID(ctx, userID, resourceOwner) + existingOTP, err := c.totpWriteModelByID(ctx, userID, resourceOwner) if err != nil { return nil, err } @@ -194,11 +191,128 @@ func (c *Commands) HumanRemoveOTP(ctx context.Context, userID, resourceOwner str return writeModelToObjectDetails(&existingOTP.WriteModel), nil } -func (c *Commands) otpWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanOTPWriteModel, err error) { +func (c *Commands) AddHumanOTPSMS(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) { + if userID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-QSF2s", "Errors.User.UserIDMissing") + } + if err := authz.UserIDInCTX(ctx, userID); err != nil { + return nil, err + } + otpWriteModel, err := c.otpSMSWriteModelByID(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if otpWriteModel.otpAdded { + return nil, caos_errs.ThrowAlreadyExists(nil, "COMMAND-Ad3g2", "Errors.User.MFA.OTP.AlreadyReady") + } + if !otpWriteModel.phoneVerified { + return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Q54j2", "Errors.User.MFA.OTP.NotReady") + } + userAgg := UserAggregateFromWriteModel(&otpWriteModel.WriteModel) + if err = c.pushAppendAndReduce(ctx, otpWriteModel, user.NewHumanOTPSMSAddedEvent(ctx, userAgg)); err != nil { + return nil, err + } + return writeModelToObjectDetails(&otpWriteModel.WriteModel), nil +} + +func (c *Commands) RemoveHumanOTPSMS(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) { + if userID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-S3br2", "Errors.User.UserIDMissing") + } + + existingOTP, err := c.otpSMSWriteModelByID(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if userID != authz.GetCtxData(ctx).UserID { + if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingOTP.ResourceOwner, userID); err != nil { + return nil, err + } + } + if !existingOTP.otpAdded { + return nil, caos_errs.ThrowNotFound(nil, "COMMAND-Sr3h3", "Errors.User.MFA.OTP.NotExisting") + } + userAgg := UserAggregateFromWriteModel(&existingOTP.WriteModel) + if err = c.pushAppendAndReduce(ctx, existingOTP, user.NewHumanOTPSMSRemovedEvent(ctx, userAgg)); err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingOTP.WriteModel), nil +} + +func (c *Commands) AddHumanOTPEmail(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) { + if userID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-Sg1hz", "Errors.User.UserIDMissing") + } + otpWriteModel, err := c.otpEmailWriteModelByID(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if otpWriteModel.otpAdded { + return nil, caos_errs.ThrowAlreadyExists(nil, "COMMAND-MKL2s", "Errors.User.MFA.OTP.AlreadyReady") + } + if !otpWriteModel.emailVerified { + return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-KLJ2d", "Errors.User.MFA.OTP.NotReady") + } + userAgg := UserAggregateFromWriteModel(&otpWriteModel.WriteModel) + if err = c.pushAppendAndReduce(ctx, otpWriteModel, user.NewHumanOTPEmailAddedEvent(ctx, userAgg)); err != nil { + return nil, err + } + return writeModelToObjectDetails(&otpWriteModel.WriteModel), nil +} + +func (c *Commands) RemoveHumanOTPEmail(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) { + if userID == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-S2h11", "Errors.User.UserIDMissing") + } + + existingOTP, err := c.otpEmailWriteModelByID(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + if userID != authz.GetCtxData(ctx).UserID { + if err := c.checkPermission(ctx, domain.PermissionUserWrite, existingOTP.ResourceOwner, userID); err != nil { + return nil, err + } + } + if !existingOTP.otpAdded { + return nil, caos_errs.ThrowNotFound(nil, "COMMAND-b312D", "Errors.User.MFA.OTP.NotExisting") + } + userAgg := UserAggregateFromWriteModel(&existingOTP.WriteModel) + if err = c.pushAppendAndReduce(ctx, existingOTP, user.NewHumanOTPEmailRemovedEvent(ctx, userAgg)); err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingOTP.WriteModel), nil +} + +func (c *Commands) totpWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanTOTPWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - writeModel = NewHumanOTPWriteModel(userID, resourceOwner) + writeModel = NewHumanTOTPWriteModel(userID, resourceOwner) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, nil +} + +func (c *Commands) otpSMSWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanOTPSMSWriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = NewHumanOTPSMSWriteModel(userID, resourceOwner) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + return writeModel, nil +} + +func (c *Commands) otpEmailWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanOTPEmailWriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + writeModel = NewHumanOTPEmailWriteModel(userID, resourceOwner) err = c.eventstore.FilterToQueryReducer(ctx, writeModel) if err != nil { return nil, err diff --git a/internal/command/user_human_otp_model.go b/internal/command/user_human_otp_model.go index 2e4914da5..9c9b8d2c4 100644 --- a/internal/command/user_human_otp_model.go +++ b/internal/command/user_human_otp_model.go @@ -7,15 +7,15 @@ import ( "github.com/zitadel/zitadel/internal/repository/user" ) -type HumanOTPWriteModel struct { +type HumanTOTPWriteModel struct { eventstore.WriteModel State domain.MFAState Secret *crypto.CryptoValue } -func NewHumanOTPWriteModel(userID, resourceOwner string) *HumanOTPWriteModel { - return &HumanOTPWriteModel{ +func NewHumanTOTPWriteModel(userID, resourceOwner string) *HumanTOTPWriteModel { + return &HumanTOTPWriteModel{ WriteModel: eventstore.WriteModel{ AggregateID: userID, ResourceOwner: resourceOwner, @@ -23,7 +23,7 @@ func NewHumanOTPWriteModel(userID, resourceOwner string) *HumanOTPWriteModel { } } -func (wm *HumanOTPWriteModel) Reduce() error { +func (wm *HumanTOTPWriteModel) Reduce() error { for _, event := range wm.Events { switch e := event.(type) { case *user.HumanOTPAddedEvent: @@ -40,7 +40,7 @@ func (wm *HumanOTPWriteModel) Reduce() error { return wm.WriteModel.Reduce() } -func (wm *HumanOTPWriteModel) Query() *eventstore.SearchQueryBuilder { +func (wm *HumanTOTPWriteModel) Query() *eventstore.SearchQueryBuilder { query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). AddQuery(). AggregateTypes(user.AggregateType). @@ -59,3 +59,107 @@ func (wm *HumanOTPWriteModel) Query() *eventstore.SearchQueryBuilder { } return query } + +type HumanOTPSMSWriteModel struct { + eventstore.WriteModel + + phoneVerified bool + otpAdded bool +} + +func NewHumanOTPSMSWriteModel(userID, resourceOwner string) *HumanOTPSMSWriteModel { + return &HumanOTPSMSWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: resourceOwner, + }, + } +} + +func (wm *HumanOTPSMSWriteModel) Reduce() error { + for _, event := range wm.Events { + switch event.(type) { + case *user.HumanPhoneVerifiedEvent: + wm.phoneVerified = true + case *user.HumanOTPSMSAddedEvent: + wm.otpAdded = true + case *user.HumanOTPSMSRemovedEvent: + wm.otpAdded = false + case *user.HumanPhoneRemovedEvent, + *user.UserRemovedEvent: + wm.phoneVerified = false + wm.otpAdded = false + } + } + return wm.WriteModel.Reduce() +} + +func (wm *HumanOTPSMSWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes(user.HumanPhoneVerifiedType, + user.HumanOTPSMSAddedType, + user.HumanOTPSMSRemovedType, + user.HumanPhoneRemovedType, + user.UserRemovedType, + ). + Builder() + + if wm.ResourceOwner != "" { + query.ResourceOwner(wm.ResourceOwner) + } + return query +} + +type HumanOTPEmailWriteModel struct { + eventstore.WriteModel + + emailVerified bool + otpAdded bool +} + +func NewHumanOTPEmailWriteModel(userID, resourceOwner string) *HumanOTPEmailWriteModel { + return &HumanOTPEmailWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: userID, + ResourceOwner: resourceOwner, + }, + } +} + +func (wm *HumanOTPEmailWriteModel) Reduce() error { + for _, event := range wm.Events { + switch event.(type) { + case *user.HumanEmailVerifiedEvent: + wm.emailVerified = true + case *user.HumanOTPEmailAddedEvent: + wm.otpAdded = true + case *user.HumanOTPEmailRemovedEvent: + wm.otpAdded = false + case *user.UserRemovedEvent: + wm.emailVerified = false + wm.otpAdded = false + } + } + return wm.WriteModel.Reduce() +} + +func (wm *HumanOTPEmailWriteModel) Query() *eventstore.SearchQueryBuilder { + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes(user.HumanEmailVerifiedType, + user.HumanOTPEmailAddedType, + user.HumanOTPEmailRemovedType, + user.UserRemovedType, + ). + Builder() + + if wm.ResourceOwner != "" { + query.ResourceOwner(wm.ResourceOwner) + } + return query +} diff --git a/internal/command/user_human_otp_test.go b/internal/command/user_human_otp_test.go index ae44489ae..28e825de4 100644 --- a/internal/command/user_human_otp_test.go +++ b/internal/command/user_human_otp_test.go @@ -22,7 +22,7 @@ import ( "github.com/zitadel/zitadel/internal/repository/user" ) -func TestCommandSide_AddHumanOTP(t *testing.T) { +func TestCommandSide_AddHumanTOTP(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore } @@ -223,7 +223,7 @@ func TestCommandSide_AddHumanOTP(t *testing.T) { r := &Commands{ eventstore: tt.fields.eventstore, } - got, err := r.AddHumanOTP(tt.args.ctx, tt.args.userID, tt.args.orgID) + got, err := r.AddHumanTOTP(tt.args.ctx, tt.args.userID, tt.args.orgID) if tt.res.err == nil { assert.NoError(t, err) } @@ -237,7 +237,7 @@ func TestCommandSide_AddHumanOTP(t *testing.T) { } } -func TestCommands_createHumanOTP(t *testing.T) { +func TestCommands_createHumanTOTP(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore } @@ -527,11 +527,11 @@ func TestCommands_createHumanOTP(t *testing.T) { } } -func TestCommands_HumanCheckMFAOTPSetup(t *testing.T) { +func TestCommands_HumanCheckMFATOTPSetup(t *testing.T) { ctx := authz.NewMockContext("", "org1", "user1") cryptoAlg := crypto.CreateMockEncryptionAlg(gomock.NewController(t)) - key, secret, err := domain.NewOTPKey("example.com", "user1", cryptoAlg) + key, secret, err := domain.NewTOTPKey("example.com", "user1", cryptoAlg) require.NoError(t, err) userAgg := &user.NewAggregate("user1", "org1").Aggregate @@ -697,7 +697,7 @@ func TestCommands_HumanCheckMFAOTPSetup(t *testing.T) { }, }, } - got, err := c.HumanCheckMFAOTPSetup(ctx, tt.args.userID, tt.args.code, "agent1", tt.args.resourceOwner) + got, err := c.HumanCheckMFATOTPSetup(ctx, tt.args.userID, tt.args.code, "agent1", tt.args.resourceOwner) require.ErrorIs(t, err, tt.wantErr) if tt.want { require.NotNil(t, got) @@ -707,7 +707,7 @@ func TestCommands_HumanCheckMFAOTPSetup(t *testing.T) { } } -func TestCommandSide_RemoveHumanOTP(t *testing.T) { +func TestCommandSide_RemoveHumanTOTP(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore } @@ -802,7 +802,7 @@ func TestCommandSide_RemoveHumanOTP(t *testing.T) { r := &Commands{ eventstore: tt.fields.eventstore, } - got, err := r.HumanRemoveOTP(tt.args.ctx, tt.args.userID, tt.args.orgID) + got, err := r.HumanRemoveTOTP(tt.args.ctx, tt.args.userID, tt.args.orgID) if tt.res.err == nil { assert.NoError(t, err) } @@ -815,3 +815,540 @@ func TestCommandSide_RemoveHumanOTP(t *testing.T) { }) } } + +func TestCommandSide_AddHumanOTPSMS(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + } + type ( + args struct { + ctx context.Context + userID string + resourceOwner string + } + ) + type res struct { + want *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-QSF2s", "Errors.User.UserIDMissing"), + }, + }, + { + name: "wrong user, permission denied error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "other", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), + }, + }, + { + name: "otp sms already exists, already exists error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPSMSAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowAlreadyExists(nil, "COMMAND-Ad3g2", "Errors.User.MFA.OTP.AlreadyReady"), + }, + }, + { + name: "phone not verified, precondition failed error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Q54j2", "Errors.User.MFA.OTP.NotReady"), + }, + }, + { + name: "phone removed, precondition failed error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + "+4179654321", + ), + ), + eventFromEventPusher( + user.NewHumanPhoneVerifiedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanPhoneRemovedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Q54j2", "Errors.User.MFA.OTP.NotReady"), + }, + }, + { + name: "successful add", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanPhoneChangedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + "+4179654321", + ), + ), + eventFromEventPusher( + user.NewHumanPhoneVerifiedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPSMSAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + }, + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + got, err := r.AddHumanOTPSMS(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} + +func TestCommandSide_RemoveHumanOTPSMS(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + userID string + resourceOwner string + } + ) + type res struct { + want *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-S3br2", "Errors.User.UserIDMissing"), + }, + }, + { + name: "other user not permission, permission denied error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: ctx, + userID: "other", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + }, + { + name: "otp sms not added, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowNotFound(nil, "COMMAND-Sr3h3", "Errors.User.MFA.OTP.NotExisting"), + }, + }, + { + name: "successful remove", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPSMSAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPSMSRemovedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + }, + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := r.RemoveHumanOTPSMS(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} + +func TestCommandSide_AddHumanOTPEmail(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + } + type ( + args struct { + ctx context.Context + userID string + resourceOwner string + } + ) + type res struct { + want *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-Sg1hz", "Errors.User.UserIDMissing"), + }, + }, + { + name: "otp email already exists, already exists error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPEmailAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowAlreadyExists(nil, "COMMAND-MKL2s", "Errors.User.MFA.OTP.AlreadyReady"), + }, + }, + { + name: "email not verified, precondition failed error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-KLJ2d", "Errors.User.MFA.OTP.NotReady"), + }, + }, + { + name: "successful add", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanEmailChangedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + "email@test.ch", + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPEmailAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + }, + ), + ), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + got, err := r.AddHumanOTPEmail(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} + +func TestCommandSide_RemoveHumanOTPEmail(t *testing.T) { + ctx := authz.NewMockContext("inst1", "org1", "user1") + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type ( + args struct { + ctx context.Context + userID string + resourceOwner string + } + ) + type res struct { + want *domain.ObjectDetails + err error + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: ctx, + userID: "", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-S2h11", "Errors.User.UserIDMissing"), + }, + }, + { + name: "other user not permission, permission denied error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: ctx, + userID: "other", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"), + }, + }, + { + name: "otp email not added, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.ThrowNotFound(nil, "COMMAND-b312D", "Errors.User.MFA.OTP.NotExisting"), + }, + }, + { + name: "successful remove", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanOTPEmailAddedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusherWithInstanceID("inst1", + user.NewHumanOTPEmailRemovedEvent(ctx, + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + }, + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: ctx, + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := r.RemoveHumanOTPEmail(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) + assert.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + }) + } +} diff --git a/internal/command/user_human_phone_test.go b/internal/command/user_human_phone_test.go index c7f7535a6..98ee9da7c 100644 --- a/internal/command/user_human_phone_test.go +++ b/internal/command/user_human_phone_test.go @@ -885,70 +885,70 @@ func TestCommandSide_RemoveHumanPhone(t *testing.T) { args args res res }{ - //{ - // name: "userid missing, invalid argument error", - // fields: fields{ - // eventstore: eventstoreExpect( - // t, - // ), - // }, - // args: args{ - // ctx: context.Background(), - // resourceOwner: "org1", - // }, - // res: res{ - // err: caos_errs.IsErrorInvalidArgument, - // }, - //}, - //{ - // name: "user not existing, precondition error", - // fields: fields{ - // eventstore: eventstoreExpect( - // t, - // expectFilter(), - // ), - // }, - // args: args{ - // ctx: context.Background(), - // userID: "user1", - // resourceOwner: "org1", - // }, - // res: res{ - // err: caos_errs.IsPreconditionFailed, - // }, - //}, - //{ - // name: "phone not existing, precondition error", - // 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, - // ), - // ), - // ), - // ), - // }, - // args: args{ - // ctx: context.Background(), - // userID: "user1", - // resourceOwner: "org1", - // }, - // res: res{ - // err: caos_errs.IsNotFound, - // }, - //}, + { + name: "userid missing, invalid argument error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + }, + args: args{ + ctx: context.Background(), + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + name: "user not existing, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.IsPreconditionFailed, + }, + }, + { + name: "phone not existing, precondition error", + 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, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: caos_errs.IsNotFound, + }, + }, { name: "remove phone, ok", fields: fields{ diff --git a/internal/command/user_v2_passkey_test.go b/internal/command/user_v2_passkey_test.go index 8a96d4a21..4d87e4bf4 100644 --- a/internal/command/user_v2_passkey_test.go +++ b/internal/command/user_v2_passkey_test.go @@ -57,7 +57,7 @@ func TestCommands_RegisterUserPasskey(t *testing.T) { resourceOwner: "org1", authenticator: domain.AuthenticatorAttachmentCrossPlattform, }, - wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), }, { name: "get human passwordless error", diff --git a/internal/command/user_v2_totp.go b/internal/command/user_v2_totp.go index 76840bac1..61514b0e7 100644 --- a/internal/command/user_v2_totp.go +++ b/internal/command/user_v2_totp.go @@ -29,5 +29,5 @@ func (c *Commands) CheckUserTOTP(ctx context.Context, userID, code, resourceOwne if err := authz.UserIDInCTX(ctx, userID); err != nil { return nil, err } - return c.HumanCheckMFAOTPSetup(ctx, userID, code, "", resourceOwner) + return c.HumanCheckMFATOTPSetup(ctx, userID, code, "", resourceOwner) } diff --git a/internal/command/user_v2_totp_test.go b/internal/command/user_v2_totp_test.go index 90596428a..32a5d9e09 100644 --- a/internal/command/user_v2_totp_test.go +++ b/internal/command/user_v2_totp_test.go @@ -45,7 +45,7 @@ func TestCommands_AddUserTOTP(t *testing.T) { userID: "foo", resourceowner: "org1", }, - wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), }, { name: "create otp error", @@ -191,7 +191,7 @@ func TestCommands_CheckUserTOTP(t *testing.T) { ctx := authz.NewMockContext("", "org1", "user1") cryptoAlg := crypto.CreateMockEncryptionAlg(gomock.NewController(t)) - key, secret, err := domain.NewOTPKey("example.com", "user1", cryptoAlg) + key, secret, err := domain.NewTOTPKey("example.com", "user1", cryptoAlg) require.NoError(t, err) userAgg := &user.NewAggregate("user1", "org1").Aggregate @@ -218,7 +218,7 @@ func TestCommands_CheckUserTOTP(t *testing.T) { args: args{ userID: "foo", }, - wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), }, { name: "success", diff --git a/internal/command/user_v2_u2f_test.go b/internal/command/user_v2_u2f_test.go index ebab0a4ce..6ae5bf6e2 100644 --- a/internal/command/user_v2_u2f_test.go +++ b/internal/command/user_v2_u2f_test.go @@ -52,7 +52,7 @@ func TestCommands_RegisterUserU2F(t *testing.T) { userID: "foo", resourceOwner: "org1", }, - wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), + wantErr: caos_errs.ThrowPermissionDenied(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"), }, { name: "get human passwordless error", diff --git a/internal/domain/auth_request.go b/internal/domain/auth_request.go index 765fead8a..f7f4da19f 100644 --- a/internal/domain/auth_request.go +++ b/internal/domain/auth_request.go @@ -102,7 +102,7 @@ const ( type MFAType int const ( - MFATypeOTP MFAType = iota + MFATypeTOTP MFAType = iota MFATypeU2F MFATypeU2FUserVerification ) diff --git a/internal/domain/human_otp.go b/internal/domain/human_otp.go index 479814340..8dd9ddcb3 100644 --- a/internal/domain/human_otp.go +++ b/internal/domain/human_otp.go @@ -3,20 +3,11 @@ package domain import ( "github.com/pquerna/otp" "github.com/pquerna/otp/totp" + "github.com/zitadel/zitadel/internal/crypto" caos_errs "github.com/zitadel/zitadel/internal/errors" - es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models" ) -type OTP struct { - es_models.ObjectRoot - - Secret *crypto.CryptoValue - SecretString string - Url string - State MFAState -} - type TOTP struct { *ObjectDetails @@ -24,7 +15,7 @@ type TOTP struct { URI string } -func NewOTPKey(issuer, accountName string, cryptoAlg crypto.EncryptionAlgorithm) (*otp.Key, *crypto.CryptoValue, error) { +func NewTOTPKey(issuer, accountName string, cryptoAlg crypto.EncryptionAlgorithm) (*otp.Key, *crypto.CryptoValue, error) { key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer, AccountName: accountName}) if err != nil { return nil, nil, caos_errs.ThrowInternal(err, "TOTP-ieY3o", "Errors.Internal") @@ -36,7 +27,7 @@ func NewOTPKey(issuer, accountName string, cryptoAlg crypto.EncryptionAlgorithm) return key, encryptedSecret, nil } -func VerifyMFAOTP(code string, secret *crypto.CryptoValue, cryptoAlg crypto.EncryptionAlgorithm) error { +func VerifyTOTP(code string, secret *crypto.CryptoValue, cryptoAlg crypto.EncryptionAlgorithm) error { decrypt, err := crypto.DecryptString(secret, cryptoAlg) if err != nil { return err diff --git a/internal/domain/user.go b/internal/domain/user.go index b46d4227d..18fa2db9a 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -48,11 +48,13 @@ type UserAuthMethodType int32 const ( UserAuthMethodTypeUnspecified UserAuthMethodType = iota - UserAuthMethodTypeOTP + UserAuthMethodTypeTOTP UserAuthMethodTypeU2F UserAuthMethodTypePasswordless UserAuthMethodTypePassword UserAuthMethodTypeIDP + UserAuthMethodTypeOTPSMS + UserAuthMethodTypeOTPEmail userAuthMethodTypeCount ) @@ -67,15 +69,14 @@ func HasMFA(methods []UserAuthMethodType) bool { var factors int for _, method := range methods { switch method { - case UserAuthMethodTypePassword: - factors++ case UserAuthMethodTypePasswordless: return true - case UserAuthMethodTypeU2F: - factors++ - case UserAuthMethodTypeOTP: - factors++ - case UserAuthMethodTypeIDP: + case UserAuthMethodTypePassword, + UserAuthMethodTypeU2F, + UserAuthMethodTypeTOTP, + UserAuthMethodTypeOTPSMS, + UserAuthMethodTypeOTPEmail, + UserAuthMethodTypeIDP: factors++ case UserAuthMethodTypeUnspecified, userAuthMethodTypeCount: diff --git a/internal/eventstore/eventstore.go b/internal/eventstore/eventstore.go index 216a29bde..67ab23591 100644 --- a/internal/eventstore/eventstore.go +++ b/internal/eventstore/eventstore.go @@ -315,6 +315,9 @@ func GenericEventMapper[T any, PT BaseEventSetter[T]](event *repository.Event) ( e := PT(new(T)) e.SetBaseEvent(BaseEventFromRepo(event)) + if len(event.Data) == 0 { + return e, nil + } err := json.Unmarshal(event.Data, e) if err != nil { return nil, errors.ThrowInternal(err, "V2-Thai6", "unable to unmarshal event") diff --git a/internal/integration/integration.go b/internal/integration/integration.go index 8634095f3..0912f8dc0 100644 --- a/internal/integration/integration.go +++ b/internal/integration/integration.go @@ -248,7 +248,11 @@ func (s *Tester) WithInstanceAuthorization(ctx context.Context, u UserType, inst if u == SystemUser { s.ensureSystemUser() } - return metadata.AppendToOutgoingContext(ctx, "Authorization", fmt.Sprintf("Bearer %s", s.Users.Get(instanceID, u).Token)) + return s.WithAuthorizationToken(ctx, s.Users.Get(instanceID, u).Token) +} + +func (s *Tester) WithAuthorizationToken(ctx context.Context, token string) context.Context { + return metadata.AppendToOutgoingContext(ctx, "Authorization", fmt.Sprintf("Bearer %s", token)) } func (s *Tester) ensureSystemUser() { diff --git a/internal/query/projection/user_auth_method.go b/internal/query/projection/user_auth_method.go index ee6f92a40..ddaa53c2b 100644 --- a/internal/query/projection/user_auth_method.go +++ b/internal/query/projection/user_auth_method.go @@ -89,6 +89,14 @@ func (p *userAuthMethodProjection) reducers() []handler.AggregateReducer { Event: user.HumanMFAOTPVerifiedType, Reduce: p.reduceActivateEvent, }, + { + Event: user.HumanOTPSMSAddedType, + Reduce: p.reduceAddAuthMethod, + }, + { + Event: user.HumanOTPEmailAddedType, + Reduce: p.reduceAddAuthMethod, + }, { Event: user.HumanPasswordlessTokenRemovedType, Reduce: p.reduceRemoveAuthMethod, @@ -101,6 +109,14 @@ func (p *userAuthMethodProjection) reducers() []handler.AggregateReducer { Event: user.HumanMFAOTPRemovedType, Reduce: p.reduceRemoveAuthMethod, }, + { + Event: user.HumanOTPSMSRemovedType, + Reduce: p.reduceRemoveAuthMethod, + }, + { + Event: user.HumanOTPEmailRemovedType, + Reduce: p.reduceRemoveAuthMethod, + }, }, }, { @@ -135,7 +151,7 @@ func (p *userAuthMethodProjection) reduceInitAuthMethod(event eventstore.Event) methodType = domain.UserAuthMethodTypeU2F tokenID = e.WebAuthNTokenID case *user.HumanOTPAddedEvent: - methodType = domain.UserAuthMethodTypeOTP + methodType = domain.UserAuthMethodTypeTOTP default: return nil, errors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType}) } @@ -178,7 +194,7 @@ func (p *userAuthMethodProjection) reduceActivateEvent(event eventstore.Event) ( tokenID = e.WebAuthNTokenID name = e.WebAuthNTokenName case *user.HumanOTPVerifiedEvent: - methodType = domain.UserAuthMethodTypeOTP + methodType = domain.UserAuthMethodTypeTOTP default: return nil, errors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType}) @@ -202,6 +218,34 @@ func (p *userAuthMethodProjection) reduceActivateEvent(event eventstore.Event) ( ), nil } +func (p *userAuthMethodProjection) reduceAddAuthMethod(event eventstore.Event) (*handler.Statement, error) { + var methodType domain.UserAuthMethodType + switch event.(type) { + case *user.HumanOTPSMSAddedEvent: + methodType = domain.UserAuthMethodTypeOTPSMS + case *user.HumanOTPEmailAddedEvent: + methodType = domain.UserAuthMethodTypeOTPEmail + default: + return nil, errors.ThrowInvalidArgumentf(nil, "PROJE-DS4g3", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanOTPSMSAddedType, user.HumanOTPEmailAddedType}) + } + + return crdb.NewCreateStatement( + event, + []handler.Column{ + handler.NewCol(UserAuthMethodTokenIDCol, ""), + handler.NewCol(UserAuthMethodCreationDateCol, event.CreationDate()), + handler.NewCol(UserAuthMethodChangeDateCol, event.CreationDate()), + handler.NewCol(UserAuthMethodResourceOwnerCol, event.Aggregate().ResourceOwner), + handler.NewCol(UserAuthMethodInstanceIDCol, event.Aggregate().InstanceID), + handler.NewCol(UserAuthMethodUserIDCol, event.Aggregate().ID), + handler.NewCol(UserAuthMethodSequenceCol, event.Sequence()), + handler.NewCol(UserAuthMethodStateCol, domain.MFAStateReady), + handler.NewCol(UserAuthMethodTypeCol, methodType), + handler.NewCol(UserAuthMethodNameCol, ""), + }, + ), nil +} + func (p *userAuthMethodProjection) reduceRemoveAuthMethod(event eventstore.Event) (*handler.Statement, error) { var tokenID string var methodType domain.UserAuthMethodType @@ -213,10 +257,17 @@ func (p *userAuthMethodProjection) reduceRemoveAuthMethod(event eventstore.Event methodType = domain.UserAuthMethodTypeU2F tokenID = e.WebAuthNTokenID case *user.HumanOTPRemovedEvent: - methodType = domain.UserAuthMethodTypeOTP + methodType = domain.UserAuthMethodTypeTOTP + case *user.HumanOTPSMSRemovedEvent, + *user.HumanPhoneRemovedEvent: + methodType = domain.UserAuthMethodTypeOTPSMS + case *user.HumanOTPEmailRemovedEvent: + methodType = domain.UserAuthMethodTypeOTPEmail default: - return nil, errors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType}) + return nil, errors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", + []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType, user.HumanMFAOTPRemovedType, + user.HumanOTPSMSRemovedType, user.HumanPhoneRemovedType, user.HumanOTPEmailRemovedType}) } conditions := []handler.Condition{ handler.NewCond(UserAuthMethodUserIDCol, event.Aggregate().ID), diff --git a/internal/query/projection/user_auth_method_test.go b/internal/query/projection/user_auth_method_test.go index 252df8fe0..632f12ae9 100644 --- a/internal/query/projection/user_auth_method_test.go +++ b/internal/query/projection/user_auth_method_test.go @@ -98,7 +98,7 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) { }, }, { - name: "reduceAddedOTP", + name: "reduceAddedTOTP", args: args{ event: getEvent(testEvent( repository.EventType(user.HumanMFAOTPAddedType), @@ -125,7 +125,7 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) { "agg-id", uint64(15), domain.MFAStateNotReady, - domain.UserAuthMethodTypeOTP, + domain.UserAuthMethodTypeTOTP, "", }, }, @@ -208,7 +208,7 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) { }, }, { - name: "reduceVerifiedOTP", + name: "reduceVerifiedTOTP", args: args{ event: getEvent(testEvent( repository.EventType(user.HumanMFAOTPVerifiedType), @@ -232,7 +232,7 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) { "", domain.MFAStateReady, "agg-id", - domain.UserAuthMethodTypeOTP, + domain.UserAuthMethodTypeTOTP, "ro-id", "", "instance-id", @@ -242,6 +242,256 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) { }, }, }, + { + name: "reduceAddedOTPSMS", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanOTPSMSAddedType), + user.AggregateType, + nil, + ), eventstore.GenericEventMapper[user.HumanOTPSMSAddedEvent]), + }, + reduce: (&userAuthMethodProjection{}).reduceAddAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.user_auth_methods4 (token_id, creation_date, change_date, resource_owner, instance_id, user_id, sequence, state, method_type, name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + expectedArgs: []interface{}{ + "", + anyArg{}, + anyArg{}, + "ro-id", + "instance-id", + "agg-id", + uint64(15), + domain.MFAStateReady, + domain.UserAuthMethodTypeOTPSMS, + "", + }, + }, + }, + }, + }, + }, + { + name: "reduceAddedOTPEmail", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanOTPEmailAddedType), + user.AggregateType, + nil, + ), eventstore.GenericEventMapper[user.HumanOTPEmailAddedEvent]), + }, + reduce: (&userAuthMethodProjection{}).reduceAddAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO projections.user_auth_methods4 (token_id, creation_date, change_date, resource_owner, instance_id, user_id, sequence, state, method_type, name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + expectedArgs: []interface{}{ + "", + anyArg{}, + anyArg{}, + "ro-id", + "instance-id", + "agg-id", + uint64(15), + domain.MFAStateReady, + domain.UserAuthMethodTypeOTPEmail, + "", + }, + }, + }, + }, + }, + }, + { + name: "reduceRemoveOTPPasswordless", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanPasswordlessTokenRemovedType), + user.AggregateType, + []byte(`{ + "webAuthNTokenId": "token-id" + }`), + ), user.HumanPasswordlessRemovedEventMapper), + }, + reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4) AND (token_id = $5)", + expectedArgs: []interface{}{ + "agg-id", + domain.UserAuthMethodTypePasswordless, + "ro-id", + "instance-id", + "token-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceRemoveOTPU2F", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanU2FTokenRemovedType), + user.AggregateType, + []byte(`{ + "webAuthNTokenId": "token-id" + }`), + ), user.HumanU2FRemovedEventMapper), + }, + reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4) AND (token_id = $5)", + expectedArgs: []interface{}{ + "agg-id", + domain.UserAuthMethodTypeU2F, + "ro-id", + "instance-id", + "token-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceRemoveTOTP", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanMFAOTPRemovedType), + user.AggregateType, + nil, + ), user.HumanOTPRemovedEventMapper), + }, + reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + "agg-id", + domain.UserAuthMethodTypeTOTP, + "ro-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceRemoveOTPSMS", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanOTPSMSRemovedType), + user.AggregateType, + nil, + ), eventstore.GenericEventMapper[user.HumanOTPSMSRemovedEvent]), + }, + reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + "agg-id", + domain.UserAuthMethodTypeOTPSMS, + "ro-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceRemovePhone", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanPhoneRemovedType), + user.AggregateType, + nil, + ), user.HumanPhoneRemovedEventMapper), + }, + reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + "agg-id", + domain.UserAuthMethodTypeOTPSMS, + "ro-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceRemoveOTPEmail", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.HumanOTPEmailRemovedType), + user.AggregateType, + nil, + ), eventstore.GenericEventMapper[user.HumanOTPEmailRemovedEvent]), + }, + reduce: (&userAuthMethodProjection{}).reduceRemoveAuthMethod, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.user_auth_methods4 WHERE (user_id = $1) AND (method_type = $2) AND (resource_owner = $3) AND (instance_id = $4)", + expectedArgs: []interface{}{ + "agg-id", + domain.UserAuthMethodTypeOTPEmail, + "ro-id", + "instance-id", + }, + }, + }, + }, + }, + }, { name: "org reduceOwnerRemoved", reduce: (&userAuthMethodProjection{}).reduceOwnerRemoved, diff --git a/internal/query/user_auth_method_test.go b/internal/query/user_auth_method_test.go index ec38d04e5..2cfe553e0 100644 --- a/internal/query/user_auth_method_test.go +++ b/internal/query/user_auth_method_test.go @@ -280,7 +280,7 @@ func Test_UserAuthMethodPrepares(t *testing.T) { }, { true, - domain.UserAuthMethodTypeOTP, + domain.UserAuthMethodTypeTOTP, 1, }, }, @@ -292,7 +292,7 @@ func Test_UserAuthMethodPrepares(t *testing.T) { }, AuthMethodTypes: []domain.UserAuthMethodType{ domain.UserAuthMethodTypePasswordless, - domain.UserAuthMethodTypeOTP, + domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypePassword, domain.UserAuthMethodTypeIDP, }, @@ -399,7 +399,7 @@ func Test_UserAuthMethodPrepares(t *testing.T) { }, { true, - domain.UserAuthMethodTypeOTP, + domain.UserAuthMethodTypeTOTP, 1, true, true, @@ -411,7 +411,7 @@ func Test_UserAuthMethodPrepares(t *testing.T) { object: &testUserAuthMethodTypesRequired{ authMethods: []domain.UserAuthMethodType{ domain.UserAuthMethodTypePasswordless, - domain.UserAuthMethodTypeOTP, + domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypePassword, domain.UserAuthMethodTypeIDP, }, diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go index 1505c31cf..3a1247e52 100644 --- a/internal/repository/user/eventstore.go +++ b/internal/repository/user/eventstore.go @@ -88,6 +88,14 @@ func RegisterEventMappers(es *eventstore.Eventstore) { RegisterFilterEventMapper(AggregateType, HumanMFAOTPRemovedType, HumanOTPRemovedEventMapper). RegisterFilterEventMapper(AggregateType, HumanMFAOTPCheckSucceededType, HumanOTPCheckSucceededEventMapper). RegisterFilterEventMapper(AggregateType, HumanMFAOTPCheckFailedType, HumanOTPCheckFailedEventMapper). + RegisterFilterEventMapper(AggregateType, HumanOTPSMSAddedType, eventstore.GenericEventMapper[HumanOTPSMSAddedEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPSMSRemovedType, eventstore.GenericEventMapper[HumanOTPSMSRemovedEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPSMSCheckSucceededType, eventstore.GenericEventMapper[HumanOTPSMSCheckSucceededEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPSMSCheckFailedType, eventstore.GenericEventMapper[HumanOTPSMSCheckFailedEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPEmailAddedType, eventstore.GenericEventMapper[HumanOTPEmailAddedEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPEmailRemovedType, eventstore.GenericEventMapper[HumanOTPEmailRemovedEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPEmailCheckSucceededType, eventstore.GenericEventMapper[HumanOTPEmailCheckSucceededEvent]). + RegisterFilterEventMapper(AggregateType, HumanOTPEmailCheckFailedType, eventstore.GenericEventMapper[HumanOTPEmailCheckFailedEvent]). RegisterFilterEventMapper(AggregateType, HumanU2FTokenAddedType, HumanU2FAddedEventMapper). RegisterFilterEventMapper(AggregateType, HumanU2FTokenVerifiedType, HumanU2FVerifiedEventMapper). RegisterFilterEventMapper(AggregateType, HumanU2FTokenSignCountChangedType, HumanU2FSignCountChangedEventMapper). diff --git a/internal/repository/user/human_mfa_otp.go b/internal/repository/user/human_mfa_otp.go index e28ece9c8..3427dbbfc 100644 --- a/internal/repository/user/human_mfa_otp.go +++ b/internal/repository/user/human_mfa_otp.go @@ -12,12 +12,22 @@ import ( ) const ( - otpEventPrefix = mfaEventPrefix + "otp." - HumanMFAOTPAddedType = otpEventPrefix + "added" - HumanMFAOTPVerifiedType = otpEventPrefix + "verified" - HumanMFAOTPRemovedType = otpEventPrefix + "removed" - HumanMFAOTPCheckSucceededType = otpEventPrefix + "check.succeeded" - HumanMFAOTPCheckFailedType = otpEventPrefix + "check.failed" + otpEventPrefix = mfaEventPrefix + "otp." + HumanMFAOTPAddedType = otpEventPrefix + "added" + HumanMFAOTPVerifiedType = otpEventPrefix + "verified" + HumanMFAOTPRemovedType = otpEventPrefix + "removed" + HumanMFAOTPCheckSucceededType = otpEventPrefix + "check.succeeded" + HumanMFAOTPCheckFailedType = otpEventPrefix + "check.failed" + otpSMSEventPrefix = otpEventPrefix + "sms." + HumanOTPSMSAddedType = otpSMSEventPrefix + "added" + HumanOTPSMSRemovedType = otpSMSEventPrefix + "removed" + HumanOTPSMSCheckSucceededType = otpSMSEventPrefix + "check.succeeded" + HumanOTPSMSCheckFailedType = otpSMSEventPrefix + "check.failed" + otpEmailEventPrefix = otpEventPrefix + "email." + HumanOTPEmailAddedType = otpEmailEventPrefix + "added" + HumanOTPEmailRemovedType = otpEmailEventPrefix + "removed" + HumanOTPEmailCheckSucceededType = otpEmailEventPrefix + "check.succeeded" + HumanOTPEmailCheckFailedType = otpEmailEventPrefix + "check.failed" ) type HumanOTPAddedEvent struct { @@ -202,3 +212,247 @@ func HumanOTPCheckFailedEventMapper(event *repository.Event) (eventstore.Event, } return otpAdded, nil } + +type HumanOTPSMSAddedEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *HumanOTPSMSAddedEvent) Data() interface{} { + return nil +} + +func (e *HumanOTPSMSAddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPSMSAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPSMSAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *HumanOTPSMSAddedEvent { + return &HumanOTPSMSAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPSMSAddedType, + ), + } +} + +type HumanOTPSMSRemovedEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *HumanOTPSMSRemovedEvent) Data() interface{} { + return nil +} + +func (e *HumanOTPSMSRemovedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPSMSRemovedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPSMSRemovedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *HumanOTPSMSRemovedEvent { + return &HumanOTPSMSRemovedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPSMSRemovedType, + ), + } +} + +type HumanOTPSMSCheckSucceededEvent struct { + eventstore.BaseEvent `json:"-"` + *AuthRequestInfo +} + +func (e *HumanOTPSMSCheckSucceededEvent) Data() interface{} { + return e +} + +func (e *HumanOTPSMSCheckSucceededEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPSMSCheckSucceededEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPSMSCheckSucceededEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + info *AuthRequestInfo, +) *HumanOTPSMSCheckSucceededEvent { + return &HumanOTPSMSCheckSucceededEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPSMSCheckSucceededType, + ), + AuthRequestInfo: info, + } +} + +type HumanOTPSMSCheckFailedEvent struct { + eventstore.BaseEvent `json:"-"` + *AuthRequestInfo +} + +func (e *HumanOTPSMSCheckFailedEvent) Data() interface{} { + return e +} + +func (e *HumanOTPSMSCheckFailedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPSMSCheckFailedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPSMSCheckFailedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + info *AuthRequestInfo, +) *HumanOTPSMSCheckFailedEvent { + return &HumanOTPSMSCheckFailedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPSMSCheckFailedType, + ), + AuthRequestInfo: info, + } +} + +type HumanOTPEmailAddedEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *HumanOTPEmailAddedEvent) Data() interface{} { + return nil +} + +func (e *HumanOTPEmailAddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPEmailAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPEmailAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *HumanOTPEmailAddedEvent { + return &HumanOTPEmailAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPEmailAddedType, + ), + } +} + +type HumanOTPEmailRemovedEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func (e *HumanOTPEmailRemovedEvent) Data() interface{} { + return nil +} + +func (e *HumanOTPEmailRemovedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPEmailRemovedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPEmailRemovedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *HumanOTPEmailRemovedEvent { + return &HumanOTPEmailRemovedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPEmailRemovedType, + ), + } +} + +type HumanOTPEmailCheckSucceededEvent struct { + eventstore.BaseEvent `json:"-"` + *AuthRequestInfo +} + +func (e *HumanOTPEmailCheckSucceededEvent) Data() interface{} { + return e +} + +func (e *HumanOTPEmailCheckSucceededEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPEmailCheckSucceededEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPEmailCheckSucceededEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + info *AuthRequestInfo, +) *HumanOTPEmailCheckSucceededEvent { + return &HumanOTPEmailCheckSucceededEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPEmailCheckSucceededType, + ), + AuthRequestInfo: info, + } +} + +type HumanOTPEmailCheckFailedEvent struct { + eventstore.BaseEvent `json:"-"` + *AuthRequestInfo +} + +func (e *HumanOTPEmailCheckFailedEvent) Data() interface{} { + return e +} + +func (e *HumanOTPEmailCheckFailedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *HumanOTPEmailCheckFailedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +} + +func NewHumanOTPEmailCheckFailedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + info *AuthRequestInfo, +) *HumanOTPEmailCheckFailedEvent { + return &HumanOTPEmailCheckFailedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + HumanOTPEmailCheckFailedType, + ), + AuthRequestInfo: info, + } +} diff --git a/internal/user/model/user_view.go b/internal/user/model/user_view.go index e2378a44e..5bc7c9a11 100644 --- a/internal/user/model/user_view.go +++ b/internal/user/model/user_view.go @@ -158,7 +158,7 @@ func (u *UserView) MFATypesSetupPossible(level domain.MFALevel, policy *domain.L switch mfaType { case domain.SecondFactorTypeTOTP: if u.OTPState != MFAStateReady { - types = append(types, domain.MFATypeOTP) + types = append(types, domain.MFATypeTOTP) } case domain.SecondFactorTypeU2F: types = append(types, domain.MFATypeU2F) @@ -183,7 +183,7 @@ func (u *UserView) MFATypesAllowed(level domain.MFALevel, policy *domain.LoginPo switch mfaType { case domain.SecondFactorTypeTOTP: if u.OTPState == MFAStateReady { - types = append(types, domain.MFATypeOTP) + types = append(types, domain.MFATypeTOTP) } case domain.SecondFactorTypeU2F: if u.IsU2FReady() { diff --git a/internal/user/repository/view/model/user_session.go b/internal/user/repository/view/model/user_session.go index b86076177..ec611c16f 100644 --- a/internal/user/repository/view/model/user_session.go +++ b/internal/user/repository/view/model/user_session.go @@ -134,11 +134,11 @@ func (v *UserSessionView) AppendEvent(event *models.Event) error { return err } if v.UserAgentID == data.UserAgentID { - v.setSecondFactorVerification(event.CreationDate, domain.MFATypeOTP) + v.setSecondFactorVerification(event.CreationDate, domain.MFATypeTOTP) } case user.UserV1MFAOTPCheckSucceededType, user.HumanMFAOTPCheckSucceededType: - v.setSecondFactorVerification(event.CreationDate, domain.MFATypeOTP) + v.setSecondFactorVerification(event.CreationDate, domain.MFATypeTOTP) case user.UserV1MFAOTPCheckFailedType, user.UserV1MFAOTPRemovedType, user.HumanMFAOTPCheckFailedType, diff --git a/proto/zitadel/auth.proto b/proto/zitadel/auth.proto index 2e291e13c..934a27570 100644 --- a/proto/zitadel/auth.proto +++ b/proto/zitadel/auth.proto @@ -647,6 +647,70 @@ service AuthService { }; } + rpc AddMyAuthFactorOTPSMS(AddMyAuthFactorOTPSMSRequest) returns (AddMyAuthFactorOTPSMSResponse) { + option (google.api.http) = { + post: "/users/me/auth_factors/otp_sms" + body: "*" + }; + option (zitadel.v1.auth_option) = { + permission: "authenticated" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "User Authentication Factor" + summary: "Add One-Time-Password (OTP) SMS"; + description: "Add a new One-Time-Password (OTP) SMS factor to the authenticated user. OTP SMS will enable the user to verify a OTP with the latest verified phone number. The phone number has to be verified to add the second factor." + }; + } + + rpc RemoveMyAuthFactorOTPSMS(RemoveMyAuthFactorOTPSMSRequest) returns (RemoveMyAuthFactorOTPSMSResponse) { + option (google.api.http) = { + delete: "/users/me/auth_factors/otp_sms" + }; + + option (zitadel.v1.auth_option) = { + permission: "authenticated" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "User Authentication Factor" + summary: "Remove One-Time-Password (OTP) SMS"; + description: "Remove the configured One-Time-Password (OTP) SMS factor of the authenticated user. As only one OTP SMS per user is allowed, the user will not have OTP SMS as a second-factor afterward." + }; + } + + rpc AddMyAuthFactorOTPEmail(AddMyAuthFactorOTPEmailRequest) returns (AddMyAuthFactorOTPEmailResponse) { + option (google.api.http) = { + post: "/users/me/auth_factors/otp_email" + body: "*" + }; + option (zitadel.v1.auth_option) = { + permission: "authenticated" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "User Authentication Factor" + summary: "Add One-Time-Password (OTP) Email"; + description: "Add a new One-Time-Password (OTP) Email factor to the authenticated user. OTP Email will enable the user to verify a OTP with the latest verified email. The email has to be verified to add the second factor." + }; + } + + rpc RemoveMyAuthFactorOTPEmail(RemoveMyAuthFactorOTPEmailRequest) returns (RemoveMyAuthFactorOTPEmailResponse) { + option (google.api.http) = { + delete: "/users/me/auth_factors/otp_email" + }; + + option (zitadel.v1.auth_option) = { + permission: "authenticated" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "User Authentication Factor" + summary: "Remove One-Time-Password (OTP) Email"; + description: "Remove the configured One-Time-Password (OTP) Email factor of the authenticated user. As only one OTP Email per user is allowed, the user will not have OTP Email as a second-factor afterward." + }; + } + rpc AddMyAuthFactorU2F(AddMyAuthFactorU2FRequest) returns (AddMyAuthFactorU2FResponse) { option (google.api.http) = { post: "/users/me/auth_factors/u2f" @@ -1340,6 +1404,34 @@ message RemoveMyAuthFactorOTPResponse { zitadel.v1.ObjectDetails details = 1; } +//This is an empty request +message AddMyAuthFactorOTPSMSRequest {} + +message AddMyAuthFactorOTPSMSResponse { + zitadel.v1.ObjectDetails details = 1; +} + +//This is an empty request +message RemoveMyAuthFactorOTPSMSRequest {} + +message RemoveMyAuthFactorOTPSMSResponse { + zitadel.v1.ObjectDetails details = 1; +} + +//This is an empty request +message AddMyAuthFactorOTPEmailRequest {} + +message AddMyAuthFactorOTPEmailResponse { + zitadel.v1.ObjectDetails details = 1; +} + +//This is an empty request +message RemoveMyAuthFactorOTPEmailRequest {} + +message RemoveMyAuthFactorOTPEmailResponse { + zitadel.v1.ObjectDetails details = 1; +} + message RemoveMyAuthFactorU2FRequest { string token_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; } diff --git a/proto/zitadel/user.proto b/proto/zitadel/user.proto index 73cc14f7a..bbe4f0be5 100644 --- a/proto/zitadel/user.proto +++ b/proto/zitadel/user.proto @@ -342,12 +342,22 @@ message AuthFactor { oneof type { AuthFactorOTP otp = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "one type use OTP or U2F" + description: "one type use OTP, OTPSMS, OTPEmail or U2F" } ]; AuthFactorU2F u2f = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "one type use OTP or U2F" + description: "one type use OTP, OTPSMS, OTPEmail or U2F" + } + ]; + AuthFactorOTPSMS otp_sms = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "one type use OTP, OTPSMS, OTPEmail or U2F" + } + ]; + AuthFactorOTPEmail otp_email = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "one type use OTP, OTPSMS, OTPEmail or U2F" } ]; } @@ -361,6 +371,8 @@ enum AuthFactorState { } message AuthFactorOTP {} +message AuthFactorOTPSMS {} +message AuthFactorOTPEmail {} message AuthFactorU2F { string id = 1 [ diff --git a/proto/zitadel/user/v2alpha/user_service.proto b/proto/zitadel/user/v2alpha/user_service.proto index 50562bcf5..40e3ea4af 100644 --- a/proto/zitadel/user/v2alpha/user_service.proto +++ b/proto/zitadel/user/v2alpha/user_service.proto @@ -317,6 +317,96 @@ service UserService { }; } + rpc AddOTPSMS (AddOTPSMSRequest) returns (AddOTPSMSResponse) { + option (google.api.http) = { + post: "/v2alpha/users/{user_id}/otp_sms" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Add OTP SMS for a user"; + description: "Add a new One-Time-Password (OTP) SMS factor to the authenticated user. OTP SMS will enable the user to verify a OTP with the latest verified phone number. The phone number has to be verified to add the second factor." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc RemoveOTPSMS (RemoveOTPSMSRequest) returns (RemoveOTPSMSResponse) { + option (google.api.http) = { + delete: "/v2alpha/users/{user_id}/otp_sms" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Remove One-Time-Password (OTP) SMS from a user"; + description: "Remove the configured One-Time-Password (OTP) SMS factor of the authenticated user. As only one OTP SMS per user is allowed, the user will not have OTP SMS as a second-factor afterward." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc AddOTPEmail (AddOTPEmailRequest) returns (AddOTPEmailResponse) { + option (google.api.http) = { + post: "/v2alpha/users/{user_id}/otp_email" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Add OTP Email for a user"; + description: "Add a new One-Time-Password (OTP) Email factor to the authenticated user. OTP Email will enable the user to verify a OTP with the latest verified email. The email has to be verified to add the second factor." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc RemoveOTPEmail (RemoveOTPEmailRequest) returns (RemoveOTPEmailResponse) { + option (google.api.http) = { + delete: "/v2alpha/users/{user_id}/otp_email" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Remove One-Time-Password (OTP) Email from a user"; + description: "Remove the configured One-Time-Password (OTP) Email factor of the authenticated user. As only one OTP Email per user is allowed, the user will not have OTP Email as a second-factor afterward." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + // Start an IDP authentication (for external login, registration or linking) rpc StartIdentityProviderFlow (StartIdentityProviderFlowRequest) returns (StartIdentityProviderFlowResponse) { option (google.api.http) = { @@ -779,6 +869,70 @@ message VerifyTOTPRegistrationResponse { zitadel.object.v2alpha.Details details = 1; } +message AddOTPSMSRequest { + 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: "\"163840776835432705\""; + } + ]; +} + +message AddOTPSMSResponse { + zitadel.object.v2alpha.Details details = 1; +} + +message RemoveOTPSMSRequest { + 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: "\"163840776835432705\""; + } + ]; +} + +message RemoveOTPSMSResponse { + zitadel.object.v2alpha.Details details = 1; +} + +message AddOTPEmailRequest { + 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: "\"163840776835432705\""; + } + ]; +} + +message AddOTPEmailResponse { + zitadel.object.v2alpha.Details details = 1; +} + +message RemoveOTPEmailRequest { + 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: "\"163840776835432705\""; + } + ]; +} + +message RemoveOTPEmailResponse { + zitadel.object.v2alpha.Details details = 1; +} + message CreatePasskeyRegistrationLinkRequest{ string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -979,4 +1133,6 @@ enum AuthenticationMethodType { AUTHENTICATION_METHOD_TYPE_IDP = 3; AUTHENTICATION_METHOD_TYPE_TOTP = 4; AUTHENTICATION_METHOD_TYPE_U2F = 5; + AUTHENTICATION_METHOD_TYPE_OTP_SMS = 6; + AUTHENTICATION_METHOD_TYPE_OTP_EMAIL = 7; }