+ {{ if or .IsCreationAllowed }}
{{t "ExternalRegistrationUserOverview.FirstnameLabel"}}
@@ -85,8 +89,9 @@
+ {{end}}
- {{ if or .TOSLink .PrivacyLink }}
+ {{ if and (or .IsLinkingAllowed .IsCreationAllowed) (or .TOSLink .PrivacyLink) }}
{{t "ExternalNotFound.TosAndPrivacyLabel"}}
{{ if .TOSLink }}
diff --git a/internal/command/idp_model.go b/internal/command/idp_model.go
index 417f984648..e01d831247 100644
--- a/internal/command/idp_model.go
+++ b/internal/command/idp_model.go
@@ -189,6 +189,10 @@ func (wm *OAuthIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encry
)
}
+func (wm *OAuthIDPWriteModel) GetProviderOptions() idp.Options {
+ return wm.Options
+}
+
type OIDCIDPWriteModel struct {
eventstore.WriteModel
@@ -310,7 +314,10 @@ func (wm *OIDCIDPWriteModel) NewChanges(
// reduceIDPConfigAddedEvent handles old idpConfig events
func (wm *OIDCIDPWriteModel) reduceIDPConfigAddedEvent(e *idpconfig.IDPConfigAddedEvent) {
wm.Name = e.Name
+ wm.Options.IsCreationAllowed = true
+ wm.Options.IsLinkingAllowed = true
wm.Options.IsAutoCreation = e.AutoRegister
+ wm.Options.IsAutoUpdate = false
wm.State = domain.IDPStateActive
}
@@ -382,6 +389,10 @@ func (wm *OIDCIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encryp
)
}
+func (wm *OIDCIDPWriteModel) GetProviderOptions() idp.Options {
+ return wm.Options
+}
+
type JWTIDPWriteModel struct {
eventstore.WriteModel
@@ -483,7 +494,10 @@ func (wm *JWTIDPWriteModel) NewChanges(
// reduceIDPConfigAddedEvent handles old idpConfig events
func (wm *JWTIDPWriteModel) reduceIDPConfigAddedEvent(e *idpconfig.IDPConfigAddedEvent) {
wm.Name = e.Name
+ wm.Options.IsCreationAllowed = true
+ wm.Options.IsLinkingAllowed = true
wm.Options.IsAutoCreation = e.AutoRegister
+ wm.Options.IsAutoUpdate = false
wm.State = domain.IDPStateActive
}
@@ -546,6 +560,10 @@ func (wm *JWTIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encrypt
)
}
+func (wm *JWTIDPWriteModel) GetProviderOptions() idp.Options {
+ return wm.Options
+}
+
type AzureADIDPWriteModel struct {
eventstore.WriteModel
@@ -690,6 +708,10 @@ func (wm *AzureADIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Enc
)
}
+func (wm *AzureADIDPWriteModel) GetProviderOptions() idp.Options {
+ return wm.Options
+}
+
type GitHubIDPWriteModel struct {
eventstore.WriteModel
@@ -803,6 +825,10 @@ func (wm *GitHubIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encr
)
}
+func (wm *GitHubIDPWriteModel) GetProviderOptions() idp.Options {
+ return wm.Options
+}
+
type GitHubEnterpriseIDPWriteModel struct {
eventstore.WriteModel
@@ -947,6 +973,10 @@ func (wm *GitHubEnterpriseIDPWriteModel) ToProvider(callbackURL string, idpAlg c
)
}
+func (wm *GitHubEnterpriseIDPWriteModel) GetProviderOptions() idp.Options {
+ return wm.Options
+}
+
type GitLabIDPWriteModel struct {
eventstore.WriteModel
@@ -1061,6 +1091,10 @@ func (wm *GitLabIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encr
)
}
+func (wm *GitLabIDPWriteModel) GetProviderOptions() idp.Options {
+ return wm.Options
+}
+
type GitLabSelfHostedIDPWriteModel struct {
eventstore.WriteModel
@@ -1185,6 +1219,10 @@ func (wm *GitLabSelfHostedIDPWriteModel) ToProvider(callbackURL string, idpAlg c
)
}
+func (wm *GitLabSelfHostedIDPWriteModel) GetProviderOptions() idp.Options {
+ return wm.Options
+}
+
type GoogleIDPWriteModel struct {
eventstore.WriteModel
@@ -1306,6 +1344,10 @@ func (wm *GoogleIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encr
)
}
+func (wm *GoogleIDPWriteModel) GetProviderOptions() idp.Options {
+ return wm.Options
+}
+
type LDAPIDPWriteModel struct {
eventstore.WriteModel
@@ -1541,6 +1583,10 @@ func (wm *LDAPIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encryp
), nil
}
+func (wm *LDAPIDPWriteModel) GetProviderOptions() idp.Options {
+ return wm.Options
+}
+
type IDPRemoveWriteModel struct {
eventstore.WriteModel
@@ -1771,6 +1817,7 @@ func (wm *IDPTypeWriteModel) Query() *eventstore.SearchQueryBuilder {
type IDP interface {
eventstore.QueryReducer
ToProvider(string, crypto.EncryptionAlgorithm) (providers.Provider, error)
+ GetProviderOptions() idp.Options
}
type AllIDPWriteModel struct {
@@ -1863,3 +1910,7 @@ func (wm *AllIDPWriteModel) AppendEvents(events ...eventstore.Event) {
func (wm *AllIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.EncryptionAlgorithm) (providers.Provider, error) {
return wm.model.ToProvider(callbackURL, idpAlg)
}
+
+func (wm *AllIDPWriteModel) GetProviderOptions() idp.Options {
+ return wm.model.GetProviderOptions()
+}
diff --git a/internal/command/user_human.go b/internal/command/user_human.go
index 044066aba7..645d6bf9e3 100644
--- a/internal/command/user_human.go
+++ b/internal/command/user_human.go
@@ -475,7 +475,8 @@ func (c *Commands) RegisterHuman(ctx context.Context, orgID string, human *domai
if err != nil {
return nil, errors.ThrowPreconditionFailed(err, "COMMAND-Dfg3g", "Errors.Org.LoginPolicy.NotFound")
}
- if !loginPolicy.AllowRegister {
+ // check only if local registration is allowed, the idp will be checked separately
+ if !loginPolicy.AllowRegister && link == nil {
return nil, errors.ThrowPreconditionFailed(err, "COMMAND-SAbr3", "Errors.Org.LoginPolicy.RegistrationNotAllowed")
}
userEvents, registeredHuman, err := c.registerHuman(ctx, orgID, human, link, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator)
@@ -605,7 +606,7 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain.
}
for _, link := range links {
- event, err := c.addUserIDPLink(ctx, userAgg, link)
+ event, err := c.addUserIDPLink(ctx, userAgg, link, false)
if err != nil {
return nil, nil, err
}
diff --git a/internal/command/user_human_test.go b/internal/command/user_human_test.go
index d6964ac90a..4b02e6e447 100644
--- a/internal/command/user_human_test.go
+++ b/internal/command/user_human_test.go
@@ -20,6 +20,7 @@ import (
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/id"
id_mock "github.com/zitadel/zitadel/internal/id/mock"
+ "github.com/zitadel/zitadel/internal/repository/idp"
"github.com/zitadel/zitadel/internal/repository/org"
"github.com/zitadel/zitadel/internal/repository/user"
)
@@ -2000,6 +2001,31 @@ func TestCommandSide_ImportHuman(t *testing.T) {
),
),
),
+ expectFilter(
+ eventFromEventPusher(
+ org.NewIDPConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "idpID",
+ "name",
+ domain.IDPConfigTypeOIDC,
+ domain.IDPConfigStylingTypeUnspecified,
+ false,
+ ),
+ ),
+ eventFromEventPusher(
+ org.NewIDPOIDCConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "clientID",
+ "idpID",
+ "issuer",
+ "authEndpoint",
+ "tokenEndpoint",
+ nil,
+ domain.OIDCMappingFieldUnspecified,
+ domain.OIDCMappingFieldUnspecified,
+ ),
+ ),
+ ),
expectFilter(
eventFromEventPusher(
org.NewIDPConfigAddedEvent(context.Background(),
@@ -2102,6 +2128,146 @@ func TestCommandSide_ImportHuman(t *testing.T) {
},
},
},
+ {
+ name: "add human (with idp, creation not allowed), precondition error",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(
+ eventFromEventPusher(
+ org.NewDomainPolicyAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ true,
+ true,
+ true,
+ ),
+ ),
+ ),
+ expectFilter(
+ eventFromEventPusher(
+ org.NewPasswordComplexityPolicyAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ 1,
+ false,
+ false,
+ false,
+ false,
+ ),
+ ),
+ ),
+ expectFilter(
+ eventFromEventPusher(
+ org.NewIDPConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "idpID",
+ "name",
+ domain.IDPConfigTypeOIDC,
+ domain.IDPConfigStylingTypeUnspecified,
+ false,
+ ),
+ ),
+ eventFromEventPusher(
+ org.NewIDPOIDCConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "clientID",
+ "idpID",
+ "issuer",
+ "authEndpoint",
+ "tokenEndpoint",
+ nil,
+ domain.OIDCMappingFieldUnspecified,
+ domain.OIDCMappingFieldUnspecified,
+ ),
+ ),
+ eventFromEventPusher(
+ func() eventstore.Command {
+ e, _ := org.NewOIDCIDPChangedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "config1",
+ []idp.OIDCIDPChanges{
+ idp.ChangeOIDCOptions(idp.OptionChanges{IsCreationAllowed: gu.Ptr(false)}),
+ },
+ )
+ return e
+ }(),
+ ),
+ ),
+ expectFilter(
+ eventFromEventPusher(
+ org.NewIDPConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "idpID",
+ "name",
+ domain.IDPConfigTypeOIDC,
+ domain.IDPConfigStylingTypeUnspecified,
+ false,
+ ),
+ ),
+ eventFromEventPusher(
+ org.NewIDPOIDCConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "clientID",
+ "idpID",
+ "issuer",
+ "authEndpoint",
+ "tokenEndpoint",
+ nil,
+ domain.OIDCMappingFieldUnspecified,
+ domain.OIDCMappingFieldUnspecified,
+ ),
+ ),
+ eventFromEventPusher(
+ func() eventstore.Command {
+ e, _ := org.NewOIDCIDPChangedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "config1",
+ []idp.OIDCIDPChanges{
+ idp.ChangeOIDCOptions(idp.OptionChanges{IsCreationAllowed: gu.Ptr(false)}),
+ },
+ )
+ return e
+ }(),
+ ),
+ eventFromEventPusher(
+ org.NewIdentityProviderAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "idpID",
+ domain.IdentityProviderTypeOrg,
+ ),
+ ),
+ ),
+ ),
+ idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
+ userPasswordHasher: mockPasswordHasher("x"),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ human: &domain.Human{
+ Username: "username",
+ Profile: &domain.Profile{
+ FirstName: "firstname",
+ LastName: "lastname",
+ PreferredLanguage: language.English,
+ },
+ Email: &domain.Email{
+ EmailAddress: "email@test.ch",
+ IsEmailVerified: true,
+ },
+ },
+ links: []*domain.UserIDPLink{
+ {
+ IDPConfigID: "idpID",
+ ExternalUserID: "externalID",
+ DisplayName: "name",
+ },
+ },
+ secretGenerator: GetMockSecretGenerator(t),
+ },
+ res: res{
+ err: caos_errs.IsPreconditionFailed,
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -3333,6 +3499,31 @@ func TestCommandSide_RegisterHuman(t *testing.T) {
),
),
),
+ expectFilter(
+ eventFromEventPusher(
+ org.NewIDPConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "idpID",
+ "name",
+ domain.IDPConfigTypeOIDC,
+ domain.IDPConfigStylingTypeUnspecified,
+ false,
+ ),
+ ),
+ eventFromEventPusher(
+ org.NewIDPOIDCConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "clientID",
+ "idpID",
+ "issuer",
+ "authEndpoint",
+ "tokenEndpoint",
+ nil,
+ domain.OIDCMappingFieldUnspecified,
+ domain.OIDCMappingFieldUnspecified,
+ ),
+ ),
+ ),
expectPush(
[]*repository.Event{
eventFromEventPusher(
diff --git a/internal/command/user_idp_link.go b/internal/command/user_idp_link.go
index 46dd96b409..bf7b6c72c7 100644
--- a/internal/command/user_idp_link.go
+++ b/internal/command/user_idp_link.go
@@ -11,7 +11,7 @@ import (
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
-func (c *Commands) AddUserIDPLink(ctx context.Context, userID, resourceOwner string, link *domain.UserIDPLink) (_ *domain.ObjectDetails, err error) {
+func (c *Commands) AddUserIDPLink(ctx context.Context, userID, resourceOwner string, link *AddLink) (_ *domain.ObjectDetails, err error) {
if userID == "" {
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-03j8f", "Errors.IDMissing")
}
@@ -23,11 +23,7 @@ func (c *Commands) AddUserIDPLink(ctx context.Context, userID, resourceOwner str
return nil, err
}
}
-
- linkWriteModel := NewUserIDPLinkWriteModel(userID, link.IDPConfigID, link.ExternalUserID, resourceOwner)
- userAgg := UserAggregateFromWriteModel(&linkWriteModel.WriteModel)
-
- event, err := c.addUserIDPLink(ctx, userAgg, link)
+ event, err := addLink(ctx, c.eventstore.Filter, user.NewAggregate(userID, resourceOwner), link)
if err != nil {
return nil, err
}
@@ -60,7 +56,7 @@ func (c *Commands) BulkAddedUserIDPLinks(ctx context.Context, userID, resourceOw
linkWriteModel := NewUserIDPLinkWriteModel(userID, link.IDPConfigID, link.ExternalUserID, resourceOwner)
userAgg := UserAggregateFromWriteModel(&linkWriteModel.WriteModel)
- events[i], err = c.addUserIDPLink(ctx, userAgg, link)
+ events[i], err = c.addUserIDPLink(ctx, userAgg, link, true)
if err != nil {
return err
}
@@ -70,18 +66,25 @@ func (c *Commands) BulkAddedUserIDPLinks(ctx context.Context, userID, resourceOw
return err
}
-func (c *Commands) addUserIDPLink(ctx context.Context, human *eventstore.Aggregate, link *domain.UserIDPLink) (eventstore.Command, error) {
+func (c *Commands) addUserIDPLink(ctx context.Context, human *eventstore.Aggregate, link *domain.UserIDPLink, linkToExistingUser bool) (eventstore.Command, error) {
if link.AggregateID != "" && human.ID != link.AggregateID {
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-33M0g", "Errors.IDMissing")
}
if !link.IsValid() {
return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-6m9Kd", "Errors.User.ExternalIDP.Invalid")
}
-
- exists, err := ExistsIDP(ctx, c.eventstore.Filter, link.IDPConfigID, human.ResourceOwner)
- if !exists || err != nil {
+ idpWriteModel, err := IDPProviderWriteModel(ctx, c.eventstore.Filter, link.IDPConfigID)
+ if err != nil {
return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-39nfs", "Errors.IDPConfig.NotExisting")
}
+ // IDP user will either be linked or created on a new user
+ // Therefore we need to either check if linking is allowed or creation:
+ if linkToExistingUser && !idpWriteModel.GetProviderOptions().IsLinkingAllowed {
+ return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-Sfee2", "Errors.ExternalIDP.LinkingNotAllowed")
+ }
+ if !linkToExistingUser && !idpWriteModel.GetProviderOptions().IsCreationAllowed {
+ return nil, caos_errs.ThrowPreconditionFailed(err, "COMMAND-SJI3g", "Errors.ExternalIDP.CreationNotAllowed")
+ }
return user.NewUserIDPLinkAddedEvent(ctx, human, link.IDPConfigID, link.DisplayName, link.ExternalUserID), nil
}
diff --git a/internal/command/user_idp_link_test.go b/internal/command/user_idp_link_test.go
index 207a8e9df5..68d7611c57 100644
--- a/internal/command/user_idp_link_test.go
+++ b/internal/command/user_idp_link_test.go
@@ -4,6 +4,7 @@ import (
"context"
"testing"
+ "github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"golang.org/x/text/language"
@@ -12,6 +13,7 @@ import (
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
+ "github.com/zitadel/zitadel/internal/repository/idp"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/org"
"github.com/zitadel/zitadel/internal/repository/user"
@@ -28,7 +30,7 @@ func TestCommandSide_BulkAddUserIDPLinks(t *testing.T) {
links []*domain.UserIDPLink
}
type res struct {
- err func(error) bool
+ err error
}
tests := []struct {
name string
@@ -55,7 +57,7 @@ func TestCommandSide_BulkAddUserIDPLinks(t *testing.T) {
resourceOwner: "org1",
},
res: res{
- err: caos_errs.IsErrorInvalidArgument,
+ err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-03j8f", "Errors.IDMissing"),
},
},
{
@@ -71,7 +73,7 @@ func TestCommandSide_BulkAddUserIDPLinks(t *testing.T) {
resourceOwner: "org1",
},
res: res{
- err: caos_errs.IsErrorInvalidArgument,
+ err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-Ek9s", "Errors.User.ExternalIDP.MinimumExternalIDPNeeded"),
},
},
{
@@ -113,7 +115,7 @@ func TestCommandSide_BulkAddUserIDPLinks(t *testing.T) {
},
},
res: res{
- err: caos_errs.IsErrorInvalidArgument,
+ err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-33M0g", "Errors.IDMissing"),
},
},
{
@@ -155,7 +157,7 @@ func TestCommandSide_BulkAddUserIDPLinks(t *testing.T) {
},
},
res: res{
- err: caos_errs.IsErrorInvalidArgument,
+ err: caos_errs.ThrowInvalidArgument(nil, "COMMAND-6m9Kd", "Errors.User.ExternalIDP.Invalid"),
},
},
{
@@ -181,7 +183,6 @@ func TestCommandSide_BulkAddUserIDPLinks(t *testing.T) {
),
),
expectFilter(),
- expectFilter(),
),
},
args: args{
@@ -199,7 +200,112 @@ func TestCommandSide_BulkAddUserIDPLinks(t *testing.T) {
},
},
res: res{
- err: caos_errs.IsPreconditionFailed,
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-as02jin", "Errors.IDPConfig.NotExisting"),
+ },
+ },
+ {
+ name: "linking not allowed, 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.GenderFemale,
+ "email@Address.ch",
+ false,
+ ),
+ ),
+ ),
+ expectFilter(
+ eventFromEventPusher(
+ org.NewIDPConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "config1",
+ "name",
+ domain.IDPConfigTypeOIDC,
+ domain.IDPConfigStylingTypeUnspecified,
+ true,
+ ),
+ ),
+ eventFromEventPusher(
+ org.NewIDPOIDCConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "clientID",
+ "config1",
+ "issuer",
+ "authEndpoint",
+ "tokenEndpoint",
+ nil,
+ domain.OIDCMappingFieldUnspecified,
+ domain.OIDCMappingFieldUnspecified,
+ ),
+ ),
+ ),
+ expectFilter(
+ eventFromEventPusher(
+ org.NewIDPConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "config1",
+ "name",
+ domain.IDPConfigTypeOIDC,
+ domain.IDPConfigStylingTypeUnspecified,
+ true,
+ ),
+ ),
+ eventFromEventPusher(
+ org.NewIDPOIDCConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "clientID",
+ "config1",
+ "issuer",
+ "authEndpoint",
+ "tokenEndpoint",
+ nil,
+ domain.OIDCMappingFieldUnspecified,
+ domain.OIDCMappingFieldUnspecified,
+ ),
+ ),
+ eventFromEventPusher(
+ func() eventstore.Command {
+ e, _ := org.NewOIDCIDPChangedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "config1",
+ []idp.OIDCIDPChanges{
+ idp.ChangeOIDCOptions(idp.OptionChanges{IsLinkingAllowed: gu.Ptr(false)}),
+ },
+ )
+ return e
+ }(),
+ ),
+ ),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ userID: "user1",
+ resourceOwner: "org1",
+ links: []*domain.UserIDPLink{
+ {
+ ObjectRoot: models.ObjectRoot{
+ AggregateID: "user1",
+ },
+ IDPConfigID: "config1",
+ DisplayName: "name",
+ ExternalUserID: "externaluser1",
+ },
+ },
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfee2", "Errors.ExternalIDP.LinkingNotAllowed"),
},
},
{
@@ -235,6 +341,44 @@ func TestCommandSide_BulkAddUserIDPLinks(t *testing.T) {
true,
),
),
+ eventFromEventPusher(
+ org.NewIDPOIDCConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "clientID",
+ "config1",
+ "issuer",
+ "authEndpoint",
+ "tokenEndpoint",
+ nil,
+ domain.OIDCMappingFieldUnspecified,
+ domain.OIDCMappingFieldUnspecified,
+ ),
+ ),
+ ),
+ expectFilter(
+ eventFromEventPusher(
+ org.NewIDPConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "config1",
+ "name",
+ domain.IDPConfigTypeOIDC,
+ domain.IDPConfigStylingTypeUnspecified,
+ true,
+ ),
+ ),
+ eventFromEventPusher(
+ org.NewIDPOIDCConfigAddedEvent(context.Background(),
+ &org.NewAggregate("org1").Aggregate,
+ "clientID",
+ "config1",
+ "issuer",
+ "authEndpoint",
+ "tokenEndpoint",
+ nil,
+ domain.OIDCMappingFieldUnspecified,
+ domain.OIDCMappingFieldUnspecified,
+ ),
+ ),
),
expectPush(
[]*repository.Event{
@@ -290,11 +434,10 @@ func TestCommandSide_BulkAddUserIDPLinks(t *testing.T) {
),
),
),
- expectFilter(),
expectFilter(
- eventFromEventPusher(
+ eventFromEventPusherWithInstanceID("instance1",
instance.NewIDPConfigAddedEvent(context.Background(),
- &org.NewAggregate("org1").Aggregate,
+ &instance.NewAggregate("instance1").Aggregate,
"config1",
"name",
domain.IDPConfigTypeOIDC,
@@ -302,6 +445,44 @@ func TestCommandSide_BulkAddUserIDPLinks(t *testing.T) {
true,
),
),
+ eventFromEventPusherWithInstanceID("instance1",
+ instance.NewIDPOIDCConfigAddedEvent(context.Background(),
+ &instance.NewAggregate("instance1").Aggregate,
+ "clientID",
+ "config1",
+ "issuer",
+ "authEndpoint",
+ "tokenEndpoint",
+ nil,
+ domain.OIDCMappingFieldUnspecified,
+ domain.OIDCMappingFieldUnspecified,
+ ),
+ ),
+ ),
+ expectFilter(
+ eventFromEventPusherWithInstanceID("instance1",
+ instance.NewIDPConfigAddedEvent(context.Background(),
+ &instance.NewAggregate("instance1").Aggregate,
+ "config1",
+ "name",
+ domain.IDPConfigTypeOIDC,
+ domain.IDPConfigStylingTypeUnspecified,
+ true,
+ ),
+ ),
+ eventFromEventPusherWithInstanceID("instance1",
+ instance.NewIDPOIDCConfigAddedEvent(context.Background(),
+ &instance.NewAggregate("instance1").Aggregate,
+ "clientID",
+ "config1",
+ "issuer",
+ "authEndpoint",
+ "tokenEndpoint",
+ nil,
+ domain.OIDCMappingFieldUnspecified,
+ domain.OIDCMappingFieldUnspecified,
+ ),
+ ),
),
expectPush(
[]*repository.Event{
@@ -342,12 +523,7 @@ func TestCommandSide_BulkAddUserIDPLinks(t *testing.T) {
eventstore: tt.fields.eventstore,
}
err := r.BulkAddedUserIDPLinks(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, tt.args.links)
- if tt.res.err == nil {
- assert.NoError(t, err)
- }
- if tt.res.err != nil && !tt.res.err(err) {
- t.Errorf("got wrong err: %v ", err)
- }
+ assert.ErrorIs(t, err, tt.res.err)
})
}
}
From 29fa3d417c7d0fd3ae25e1fe7d8923d6ca0c388f Mon Sep 17 00:00:00 2001
From: Elio Bischof
Date: Wed, 23 Aug 2023 14:57:20 +0200
Subject: [PATCH 20/35] feat(console): enable ID token mapping for generic OIDC
provider (#6426)
* fix: use IsIdTokenMapping request property
* feat(console): oidc provider id token mapping
* fix scss
* reduce styles
* fix lint
---------
Co-authored-by: peintnermax
Co-authored-by: Livio Spring
---
.../provider-oidc/provider-oidc.component.html | 8 ++++++++
.../provider-oidc/provider-oidc.component.scss | 8 ++++++++
.../provider-oidc/provider-oidc.component.ts | 8 ++++++++
console/src/assets/i18n/bg.json | 4 +++-
console/src/assets/i18n/de.json | 4 +++-
console/src/assets/i18n/en.json | 4 +++-
console/src/assets/i18n/es.json | 4 +++-
console/src/assets/i18n/fr.json | 4 +++-
console/src/assets/i18n/it.json | 4 +++-
console/src/assets/i18n/ja.json | 4 +++-
console/src/assets/i18n/mk.json | 4 +++-
console/src/assets/i18n/pl.json | 4 +++-
console/src/assets/i18n/pt.json | 4 +++-
console/src/assets/i18n/zh.json | 4 +++-
internal/api/grpc/management/idp_converter.go | 13 +++++++------
proto/zitadel/idp.proto | 7 ++++++-
16 files changed, 70 insertions(+), 18 deletions(-)
create mode 100644 console/src/app/modules/providers/provider-oidc/provider-oidc.component.scss
diff --git a/console/src/app/modules/providers/provider-oidc/provider-oidc.component.html b/console/src/app/modules/providers/provider-oidc/provider-oidc.component.html
index a33b4aa3d9..41f8351629 100644
--- a/console/src/app/modules/providers/provider-oidc/provider-oidc.component.html
+++ b/console/src/app/modules/providers/provider-oidc/provider-oidc.component.html
@@ -78,6 +78,14 @@
+
+
+
+
{{ 'IDP.ISIDTOKENMAPPING_DESC' | translate }}
+
{{ 'IDP.ISIDTOKENMAPPING' | translate }}
+
+
+
{
@@ -131,6 +133,7 @@ export class ProviderOIDCComponent {
req.setIssuer(this.issuer?.value);
req.setScopesList(this.scopesList?.value);
req.setProviderOptions(this.options);
+ req.setIsIdTokenMapping(this.isIdTokenMapping?.value);
this.loading = true;
this.service
@@ -160,6 +163,7 @@ export class ProviderOIDCComponent {
req.setIssuer(this.issuer?.value);
req.setScopesList(this.scopesList?.value);
req.setProviderOptions(this.options);
+ req.setIsIdTokenMapping(this.isIdTokenMapping?.value);
this.loading = true;
this.service
@@ -224,4 +228,8 @@ export class ProviderOIDCComponent {
public get scopesList(): AbstractControl | null {
return this.form.get('scopesList');
}
+
+ public get isIdTokenMapping(): AbstractControl | null {
+ return this.form.get('isIdTokenMapping');
+ }
}
diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json
index 4c10757a66..1bceff83fa 100644
--- a/console/src/assets/i18n/bg.json
+++ b/console/src/assets/i18n/bg.json
@@ -1822,7 +1822,9 @@
"DELETED": "Idp премахнат успешно!",
"ADDED": "Добавено успешно.",
"REMOVED": "Премахнато успешно."
- }
+ },
+ "ISIDTOKENMAPPING": "Съответствие от ID токен",
+ "ISIDTOKENMAPPING_DESC": "Ако е избрано, информацията на доставчика се съответства от ID токена, а не от userinfo крайната точка."
},
"MFA": {
"LIST": {
diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json
index d97876fbe4..9a0c189944 100644
--- a/console/src/assets/i18n/de.json
+++ b/console/src/assets/i18n/de.json
@@ -1831,7 +1831,9 @@
"DELETED": "Idp erfolgreich gelöscht!",
"ADDED": "Erfolgreich hinzugefügt.",
"REMOVED": "Erfolgreich entfernt."
- }
+ },
+ "ISIDTOKENMAPPING": "Zuordnung vom ID-Token",
+ "ISIDTOKENMAPPING_DESC": "Legt fest, ob für das Mapping der Provider Informationen das ID-Token verwendet werden soll, anstatt des Userinfo-Endpoints."
},
"MFA": {
"LIST": {
diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json
index 34c0fce960..c1a8f2b6da 100644
--- a/console/src/assets/i18n/en.json
+++ b/console/src/assets/i18n/en.json
@@ -1828,7 +1828,9 @@
"DELETED": "Idp removed successfully!",
"ADDED": "Added successfully.",
"REMOVED": "Removed successfully."
- }
+ },
+ "ISIDTOKENMAPPING": "Map from the ID token",
+ "ISIDTOKENMAPPING_DESC": "If selected, provider information gets mapped from the ID token, not from the userinfo endpoint."
},
"MFA": {
"LIST": {
diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json
index acd565e592..afe86e5aa0 100644
--- a/console/src/assets/i18n/es.json
+++ b/console/src/assets/i18n/es.json
@@ -1828,7 +1828,9 @@
"DELETED": "¡IDP eliminado con éxito!",
"ADDED": "Añadido con éxito.",
"REMOVED": "Eliminado con éxito."
- }
+ },
+ "ISIDTOKENMAPPING": "Asignación del ID token",
+ "ISIDTOKENMAPPING_DESC": "Si se selecciona, la información del proveedor se asigna desde el ID token, no desde el punto final de userinfo."
},
"MFA": {
"LIST": {
diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json
index c4b7dd3bf7..bd4d6bf0fa 100644
--- a/console/src/assets/i18n/fr.json
+++ b/console/src/assets/i18n/fr.json
@@ -1832,7 +1832,9 @@
"DELETED": "Idp supprimé avec succès !",
"ADDED": "Ajouté avec succès.",
"REMOVED": "Suppression réussie."
- }
+ },
+ "ISIDTOKENMAPPING": "Mappage depuis le jeton ID",
+ "ISIDTOKENMAPPING_DESC": "Si sélectionné, les informations du fournisseur sont mappées à partir du jeton ID, et non à partir du point d'extrémité userinfo."
},
"MFA": {
"LIST": {
diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json
index e14a12365e..57d0383c3a 100644
--- a/console/src/assets/i18n/it.json
+++ b/console/src/assets/i18n/it.json
@@ -1832,7 +1832,9 @@
"DELETED": "IDP rimosso con successo!",
"ADDED": "Aggiunto con successo.",
"REMOVED": "Rimosso con successo."
- }
+ },
+ "ISIDTOKENMAPPING": "Mappatura dal token ID",
+ "ISIDTOKENMAPPING_DESC": "Se selezionato, le informazioni del provider vengono mappate dal token ID, non dal punto finale userinfo."
},
"MFA": {
"LIST": {
diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json
index 6eb5718f67..c5fd927465 100644
--- a/console/src/assets/i18n/ja.json
+++ b/console/src/assets/i18n/ja.json
@@ -1823,7 +1823,9 @@
"DELETED": "IDPは正常に削除されました!",
"ADDED": "正常に追加されました。",
"REMOVED": "正常に削除されました。"
- }
+ },
+ "ISIDTOKENMAPPING": "IDトークンからのマッピング",
+ "ISIDTOKENMAPPING_DESC": "選択された場合、プロバイダ情報はIDトークンからマッピングされ、userinfoエンドポイントからではありません。"
},
"MFA": {
"LIST": {
diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json
index 81b6001885..f01d931d81 100644
--- a/console/src/assets/i18n/mk.json
+++ b/console/src/assets/i18n/mk.json
@@ -1828,7 +1828,9 @@
"DELETED": "IDP успешно отстранет!",
"ADDED": "Успешно додадено.",
"REMOVED": "Успешно отстрането."
- }
+ },
+ "ISIDTOKENMAPPING": "Совпаѓање од ID токен",
+ "ISIDTOKENMAPPING_DESC": "Ако е избрано, информациите од провајдерот се мапираат од ID токенот, а не од userinfo крајната точка."
},
"MFA": {
"LIST": {
diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json
index 0c74e3de22..71aef9be52 100644
--- a/console/src/assets/i18n/pl.json
+++ b/console/src/assets/i18n/pl.json
@@ -1832,7 +1832,9 @@
"DELETED": "Dostawca tożsamości usunięty pomyślnie!",
"ADDED": "Dodano pomyślnie.",
"REMOVED": "Usunięto pomyślnie."
- }
+ },
+ "ISIDTOKENMAPPING": "Mapowanie z tokena ID",
+ "ISIDTOKENMAPPING_DESC": "Jeśli wybrane, informacje dostawcy są mapowane z tokena ID, a nie z punktu końcowego userinfo."
},
"MFA": {
"LIST": {
diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json
index 6f8d76c685..7ff90c0087 100644
--- a/console/src/assets/i18n/pt.json
+++ b/console/src/assets/i18n/pt.json
@@ -1826,7 +1826,9 @@
"DELETED": "Provedor de identidade removido com sucesso!",
"ADDED": "Adicionado com sucesso.",
"REMOVED": "Removido com sucesso."
- }
+ },
+ "ISIDTOKENMAPPING": "Mapeamento do token ID",
+ "ISIDTOKENMAPPING_DESC": "Se selecionado, as informações do provedor são mapeadas a partir do token ID, e não do ponto final userinfo."
},
"MFA": {
"LIST": {
diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json
index 3d7aaefede..9835802018 100644
--- a/console/src/assets/i18n/zh.json
+++ b/console/src/assets/i18n/zh.json
@@ -1831,7 +1831,9 @@
"DELETED": "IDP 删除成功!",
"ADDED": "添加成功。",
"REMOVED": "成功删除。"
- }
+ },
+ "ISIDTOKENMAPPING": "从ID令牌映射",
+ "ISIDTOKENMAPPING_DESC": "如果选中,提供商信息将从ID令牌映射,而不是从userinfo端点。"
},
"MFA": {
"LIST": {
diff --git a/internal/api/grpc/management/idp_converter.go b/internal/api/grpc/management/idp_converter.go
index 6b0becd98a..efce720a89 100644
--- a/internal/api/grpc/management/idp_converter.go
+++ b/internal/api/grpc/management/idp_converter.go
@@ -248,12 +248,13 @@ func updateGenericOAuthProviderToCommand(req *mgmt_pb.UpdateGenericOAuthProvider
func addGenericOIDCProviderToCommand(req *mgmt_pb.AddGenericOIDCProviderRequest) command.GenericOIDCProvider {
return command.GenericOIDCProvider{
- Name: req.Name,
- Issuer: req.Issuer,
- ClientID: req.ClientId,
- ClientSecret: req.ClientSecret,
- Scopes: req.Scopes,
- IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions),
+ Name: req.Name,
+ Issuer: req.Issuer,
+ ClientID: req.ClientId,
+ ClientSecret: req.ClientSecret,
+ Scopes: req.Scopes,
+ IsIDTokenMapping: req.IsIdTokenMapping,
+ IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions),
}
}
diff --git a/proto/zitadel/idp.proto b/proto/zitadel/idp.proto
index 645642f30c..b8c388bb6e 100644
--- a/proto/zitadel/idp.proto
+++ b/proto/zitadel/idp.proto
@@ -342,7 +342,12 @@ message GenericOIDCConfig {
description: "the scopes requested by ZITADEL during the request on the identity provider";
}
];
- bool is_id_token_mapping = 4;
+ bool is_id_token_mapping = 4 [
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ example: "true";
+ description: "if true, provider information get mapped from the id token, not from the userinfo endpoint";
+ }
+ ];
}
message GitHubConfig {
From bb40e173bdf3db2500507c5cce53c539e0b7d2a1 Mon Sep 17 00:00:00 2001
From: Livio Spring
Date: Thu, 24 Aug 2023 11:41:52 +0200
Subject: [PATCH 21/35] feat(api): add otp (sms and email) checks in session
api (#6422)
* feat: add otp (sms and email) checks in session api
* implement sending
* fix tests
* add tests
* add integration tests
* fix merge main and add tests
* put default OTP Email url into config
---------
Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
---
cmd/defaults.yaml | 1 +
cmd/start/start.go | 20 +-
internal/api/grpc/session/v2/session.go | 76 +-
.../session/v2/session_integration_test.go | 94 ++
internal/api/ui/login/login.go | 3 +
.../eventstore/token_verifier.go | 15 +-
internal/command/command.go | 6 +-
internal/command/crypto.go | 38 +-
internal/command/crypto_test.go | 64 +-
internal/command/session.go | 20 +
internal/command/session_model.go | 69 +-
internal/command/session_otp.go | 148 +++
internal/command/session_otp_test.go | 951 ++++++++++++++++++
internal/command/user_human_otp.go | 3 -
internal/domain/session.go | 26 +
.../notification/handlers/user_notifier.go | 181 +++-
internal/notification/projections.go | 10 +-
internal/notification/types/otp.go | 4 +-
internal/query/projection/session.go | 54 +-
internal/query/projection/session_test.go | 22 +-
internal/query/session.go | 30 +
internal/query/sessions_test.go | 118 ++-
internal/repository/session/eventstore.go | 6 +
internal/repository/session/session.go | 212 ++++
proto/zitadel/session/v2alpha/challenge.proto | 27 +
proto/zitadel/session/v2alpha/session.proto | 10 +
.../session/v2alpha/session_service.proto | 20 +
27 files changed, 2077 insertions(+), 151 deletions(-)
create mode 100644 internal/command/session_otp.go
create mode 100644 internal/command/session_otp_test.go
diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml
index 7f90aca5c9..0fa789baf1 100644
--- a/cmd/defaults.yaml
+++ b/cmd/defaults.yaml
@@ -307,6 +307,7 @@ Login:
MaxAge: 12h # ZITADEL_LOGIN_CACHE_MAXAGE
# 168h is 7 days, one week
SharedMaxAge: 168h # ZITADEL_LOGIN_CACHE_SHAREDMAXAGE
+ DefaultOTPEmailURLV2: "/otp/verify?loginName={{.LoginName}}&code={{.Code}}" # ZITADEL_LOGIN_CACHE_DEFAULTOTPEMAILURLV2
Console:
ShortCache:
diff --git a/cmd/start/start.go b/cmd/start/start.go
index 1191e19b21..245928d919 100644
--- a/cmd/start/start.go
+++ b/cmd/start/start.go
@@ -222,7 +222,25 @@ func startZitadel(config *Config, masterKey string, server chan<- *Server) error
actionsLogstoreSvc := logstore.New(queries, usageReporter, actionsExecutionDBEmitter, actionsExecutionStdoutEmitter)
actions.SetLogstoreService(actionsLogstoreSvc)
- notification.Start(ctx, config.Projections.Customizations["notifications"], config.Projections.Customizations["notificationsquotas"], config.Projections.Customizations["telemetry"], *config.Telemetry, config.ExternalDomain, config.ExternalPort, config.ExternalSecure, commands, queries, eventstoreClient, assets.AssetAPIFromDomain(config.ExternalSecure, config.ExternalPort), config.SystemDefaults.Notifications.FileSystemPath, keys.User, keys.SMTP, keys.SMS)
+ notification.Start(
+ ctx,
+ config.Projections.Customizations["notifications"],
+ config.Projections.Customizations["notificationsquotas"],
+ config.Projections.Customizations["telemetry"],
+ *config.Telemetry,
+ config.ExternalDomain,
+ config.ExternalPort,
+ config.ExternalSecure,
+ commands,
+ queries,
+ eventstoreClient,
+ assets.AssetAPIFromDomain(config.ExternalSecure, config.ExternalPort),
+ config.Login.DefaultOTPEmailURLV2,
+ config.SystemDefaults.Notifications.FileSystemPath,
+ keys.User,
+ keys.SMTP,
+ keys.SMS,
+ )
router := mux.NewRouter()
tlsConfig, err := config.TLS.Config()
diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go
index b1a443a0d5..fea5cd62b1 100644
--- a/internal/api/grpc/session/v2/session.go
+++ b/internal/api/grpc/session/v2/session.go
@@ -45,7 +45,10 @@ func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRe
if err != nil {
return nil, err
}
- challengeResponse, cmds := s.challengesToCommand(req.GetChallenges(), checks)
+ challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks)
+ if err != nil {
+ return nil, err
+ }
set, err := s.command.CreateSession(ctx, cmds, metadata)
if err != nil {
@@ -64,7 +67,10 @@ func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest)
if err != nil {
return nil, err
}
- challengeResponse, cmds := s.challengesToCommand(req.GetChallenges(), checks)
+ challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks)
+ if err != nil {
+ return nil, err
+ }
set, err := s.command.UpdateSession(ctx, req.GetSessionId(), req.GetSessionToken(), cmds, req.GetMetadata())
if err != nil {
@@ -121,6 +127,8 @@ func factorsToPb(s *query.Session) *session.Factors {
WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor),
Intent: intentFactorToPb(s.IntentFactor),
Totp: totpFactorToPb(s.TOTPFactor),
+ OtpSms: otpFactorToPb(s.OTPSMSFactor),
+ OtpEmail: otpFactorToPb(s.OTPEmailFactor),
}
}
@@ -161,6 +169,15 @@ func totpFactorToPb(factor query.SessionTOTPFactor) *session.TOTPFactor {
}
}
+func otpFactorToPb(factor query.SessionOTPFactor) *session.OTPFactor {
+ if factor.OTPCheckedAt.IsZero() {
+ return nil
+ }
+ return &session.OTPFactor{
+ VerifiedAt: timestamppb.New(factor.OTPCheckedAt),
+ }
+}
+
func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor {
if factor.UserID == "" || factor.UserCheckedAt.IsZero() {
return nil
@@ -240,7 +257,7 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([
if err != nil {
return nil, err
}
- sessionChecks := make([]command.SessionCommand, 0, 3)
+ sessionChecks := make([]command.SessionCommand, 0, 7)
if checkUser != nil {
user, err := checkUser.search(ctx, s.query)
if err != nil {
@@ -260,12 +277,18 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([
if totp := checks.GetTotp(); totp != nil {
sessionChecks = append(sessionChecks, command.CheckTOTP(totp.GetTotp()))
}
+ if otp := checks.GetOtpSms(); otp != nil {
+ sessionChecks = append(sessionChecks, command.CheckOTPSMS(otp.GetOtp()))
+ }
+ if otp := checks.GetOtpEmail(); otp != nil {
+ sessionChecks = append(sessionChecks, command.CheckOTPEmail(otp.GetOtp()))
+ }
return sessionChecks, nil
}
-func (s *Server) challengesToCommand(challenges *session.RequestChallenges, cmds []command.SessionCommand) (*session.Challenges, []command.SessionCommand) {
+func (s *Server) challengesToCommand(challenges *session.RequestChallenges, cmds []command.SessionCommand) (*session.Challenges, []command.SessionCommand, error) {
if challenges == nil {
- return nil, cmds
+ return nil, cmds, nil
}
resp := new(session.Challenges)
if req := challenges.GetWebAuthN(); req != nil {
@@ -273,7 +296,20 @@ func (s *Server) challengesToCommand(challenges *session.RequestChallenges, cmds
resp.WebAuthN = challenge
cmds = append(cmds, cmd)
}
- return resp, cmds
+ if req := challenges.GetOtpSms(); req != nil {
+ challenge, cmd := s.createOTPSMSChallengeCommand(req)
+ resp.OtpSms = challenge
+ cmds = append(cmds, cmd)
+ }
+ if req := challenges.GetOtpEmail(); req != nil {
+ challenge, cmd, err := s.createOTPEmailChallengeCommand(req)
+ if err != nil {
+ return nil, nil, err
+ }
+ resp.OtpEmail = challenge
+ cmds = append(cmds, cmd)
+ }
+ return resp, cmds, nil
}
func (s *Server) createWebAuthNChallengeCommand(req *session.RequestChallenges_WebAuthN) (*session.Challenges_WebAuthN, command.SessionCommand) {
@@ -299,6 +335,34 @@ func userVerificationRequirementToDomain(req session.UserVerificationRequirement
}
}
+func (s *Server) createOTPSMSChallengeCommand(req *session.RequestChallenges_OTPSMS) (*string, command.SessionCommand) {
+ if req.GetReturnCode() {
+ challenge := new(string)
+ return challenge, s.command.CreateOTPSMSChallengeReturnCode(challenge)
+ }
+
+ return nil, s.command.CreateOTPSMSChallenge()
+
+}
+
+func (s *Server) createOTPEmailChallengeCommand(req *session.RequestChallenges_OTPEmail) (*string, command.SessionCommand, error) {
+ switch t := req.GetDeliveryType().(type) {
+ case *session.RequestChallenges_OTPEmail_SendCode_:
+ cmd, err := s.command.CreateOTPEmailChallengeURLTemplate(t.SendCode.GetUrlTemplate())
+ if err != nil {
+ return nil, nil, err
+ }
+ return nil, cmd, nil
+ case *session.RequestChallenges_OTPEmail_ReturnCode_:
+ challenge := new(string)
+ return challenge, s.command.CreateOTPEmailChallengeReturnCode(challenge), nil
+ case nil:
+ return nil, s.command.CreateOTPEmailChallenge(), nil
+ default:
+ return nil, nil, caos_errs.ThrowUnimplementedf(nil, "SESSION-k3ng0", "delivery_type oneOf %T in OTPEmailChallenge not implemented", t)
+ }
+}
+
func userCheck(user *session.CheckUser) (userSearch, error) {
if user == nil {
return nil, nil
diff --git a/internal/api/grpc/session/v2/session_integration_test.go b/internal/api/grpc/session/v2/session_integration_test.go
index 6c322bc281..2e6b3de46f 100644
--- a/internal/api/grpc/session/v2/session_integration_test.go
+++ b/internal/api/grpc/session/v2/session_integration_test.go
@@ -39,6 +39,14 @@ func TestMain(m *testing.M) {
CTX, _ = Tester.WithAuthorization(ctx, integration.OrgOwner), errCtx
User = Tester.CreateHumanUser(CTX)
+ Tester.Client.UserV2.VerifyEmail(CTX, &user.VerifyEmailRequest{
+ UserId: User.GetUserId(),
+ VerificationCode: User.GetEmailCode(),
+ })
+ Tester.Client.UserV2.VerifyPhone(CTX, &user.VerifyPhoneRequest{
+ UserId: User.GetUserId(),
+ VerificationCode: User.GetPhoneCode(),
+ })
Tester.SetUserPassword(CTX, User.GetUserId(), integration.UserPassword)
Tester.RegisterUserPasskey(CTX, User.GetUserId())
return m.Run()
@@ -75,6 +83,8 @@ const (
wantWebAuthNFactorUserVerified
wantTOTPFactor
wantIntentFactor
+ wantOTPSMSFactor
+ wantOTPEmailFactor
)
func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration, want []wantFactor) {
@@ -107,6 +117,14 @@ func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration,
pf := factors.GetIntent()
assert.NotNil(t, pf)
assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
+ case wantOTPSMSFactor:
+ pf := factors.GetOtpSms()
+ assert.NotNil(t, pf)
+ assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
+ case wantOTPEmailFactor:
+ pf := factors.GetOtpEmail()
+ assert.NotNil(t, pf)
+ assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
}
}
}
@@ -362,6 +380,20 @@ func registerTOTP(ctx context.Context, t *testing.T, userID string) (secret stri
return secret
}
+func registerOTPSMS(ctx context.Context, t *testing.T, userID string) {
+ _, err := Tester.Client.UserV2.AddOTPSMS(ctx, &user.AddOTPSMSRequest{
+ UserId: userID,
+ })
+ require.NoError(t, err)
+}
+
+func registerOTPEmail(ctx context.Context, t *testing.T, userID string) {
+ _, err := Tester.Client.UserV2.AddOTPEmail(ctx, &user.AddOTPEmailRequest{
+ UserId: userID,
+ })
+ require.NoError(t, err)
+}
+
func TestServer_SetSession_flow(t *testing.T) {
// create new, empty session
createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{})
@@ -421,6 +453,8 @@ func TestServer_SetSession_flow(t *testing.T) {
userAuthCtx := Tester.WithAuthorizationToken(CTX, sessionToken)
Tester.RegisterUserU2F(userAuthCtx, User.GetUserId())
totpSecret := registerTOTP(userAuthCtx, t, User.GetUserId())
+ registerOTPSMS(userAuthCtx, t, User.GetUserId())
+ registerOTPEmail(userAuthCtx, t, User.GetUserId())
t.Run("check webauthn, user not verified (U2F)", func(t *testing.T) {
@@ -478,6 +512,66 @@ func TestServer_SetSession_flow(t *testing.T) {
sessionToken = resp.GetSessionToken()
verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantWebAuthNFactor, wantTOTPFactor)
})
+
+ t.Run("check OTP SMS", func(t *testing.T) {
+ resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ SessionToken: sessionToken,
+ Challenges: &session.RequestChallenges{
+ OtpSms: &session.RequestChallenges_OTPSMS{ReturnCode: true},
+ },
+ })
+ require.NoError(t, err)
+ verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil)
+ sessionToken = resp.GetSessionToken()
+
+ otp := resp.GetChallenges().GetOtpSms()
+ require.NotEmpty(t, otp)
+
+ resp, err = Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ SessionToken: sessionToken,
+ Checks: &session.Checks{
+ OtpSms: &session.CheckOTP{
+ Otp: otp,
+ },
+ },
+ })
+ require.NoError(t, err)
+ sessionToken = resp.GetSessionToken()
+ verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantWebAuthNFactor, wantOTPSMSFactor)
+ })
+
+ t.Run("check OTP Email", func(t *testing.T) {
+ resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ SessionToken: sessionToken,
+ Challenges: &session.RequestChallenges{
+ OtpEmail: &session.RequestChallenges_OTPEmail{
+ DeliveryType: &session.RequestChallenges_OTPEmail_ReturnCode_{},
+ },
+ },
+ })
+ require.NoError(t, err)
+ verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil)
+ sessionToken = resp.GetSessionToken()
+
+ otp := resp.GetChallenges().GetOtpEmail()
+ require.NotEmpty(t, otp)
+
+ resp, err = Client.SetSession(CTX, &session.SetSessionRequest{
+ SessionId: createResp.GetSessionId(),
+ SessionToken: sessionToken,
+ Checks: &session.Checks{
+ OtpEmail: &session.CheckOTP{
+ Otp: otp,
+ },
+ },
+ })
+ require.NoError(t, err)
+ sessionToken = resp.GetSessionToken()
+ verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantWebAuthNFactor, wantOTPEmailFactor)
+ })
}
func Test_ZITADEL_API_missing_authentication(t *testing.T) {
diff --git a/internal/api/ui/login/login.go b/internal/api/ui/login/login.go
index 1a76ad1780..64a463f77f 100644
--- a/internal/api/ui/login/login.go
+++ b/internal/api/ui/login/login.go
@@ -47,6 +47,9 @@ type Config struct {
CSRFCookieName string
Cache middleware.CacheConfig
AssetCache middleware.CacheConfig
+
+ // LoginV2
+ DefaultOTPEmailURLV2 string
}
const (
diff --git a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go
index dcdd62017b..861fd06529 100644
--- a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go
+++ b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go
@@ -206,15 +206,12 @@ func authMethodsFromSession(session *query.Session) []domain.UserAuthMethodType
types = append(types, domain.UserAuthMethodTypeTOTP)
}
*/
- // 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)
- }
- */
+ if !session.OTPSMSFactor.OTPCheckedAt.IsZero() {
+ types = append(types, domain.UserAuthMethodTypeOTPSMS)
+ }
+ if !session.OTPEmailFactor.OTPCheckedAt.IsZero() {
+ types = append(types, domain.UserAuthMethodTypeOTPEmail)
+ }
return types
}
diff --git a/internal/command/command.go b/internal/command/command.go
index 5149652739..aa8c1f3e77 100644
--- a/internal/command/command.go
+++ b/internal/command/command.go
@@ -34,8 +34,9 @@ import (
type Commands struct {
httpClient *http.Client
- checkPermission domain.PermissionCheck
- newCode cryptoCodeFunc
+ checkPermission domain.PermissionCheck
+ newCode cryptoCodeFunc
+ newCodeWithDefault cryptoCodeWithDefaultFunc
eventstore *eventstore.Eventstore
static static.Storage
@@ -122,6 +123,7 @@ func StartCommands(
httpClient: httpClient,
checkPermission: permissionCheck,
newCode: newCryptoCode,
+ newCodeWithDefault: newCryptoCodeWithDefaultConfig,
sessionTokenCreator: sessionTokenCreator(idGenerator, sessionAlg),
sessionTokenVerifier: sessionTokenVerifier,
defaultAccessTokenLifetime: defaultAccessTokenLifetime,
diff --git a/internal/command/crypto.go b/internal/command/crypto.go
index af9407e0a3..0f7fe11ce5 100644
--- a/internal/command/crypto.go
+++ b/internal/command/crypto.go
@@ -12,6 +12,10 @@ import (
type cryptoCodeFunc func(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (*CryptoCode, error)
+type cryptoCodeWithDefaultFunc func(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto, defaultConfig *crypto.GeneratorConfig) (*CryptoCode, error)
+
+var emptyConfig = &crypto.GeneratorConfig{}
+
type CryptoCode struct {
Crypted *crypto.CryptoValue
Plain string
@@ -19,7 +23,11 @@ type CryptoCode struct {
}
func newCryptoCode(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (*CryptoCode, error) {
- gen, config, err := secretGenerator(ctx, filter, typ, alg)
+ return newCryptoCodeWithDefaultConfig(ctx, filter, typ, alg, emptyConfig)
+}
+
+func newCryptoCodeWithDefaultConfig(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto, defaultConfig *crypto.GeneratorConfig) (*CryptoCode, error) {
+ gen, config, err := secretGenerator(ctx, filter, typ, alg, defaultConfig)
if err != nil {
return nil, err
}
@@ -35,15 +43,15 @@ func newCryptoCode(ctx context.Context, filter preparation.FilterToQueryReducer,
}
func verifyCryptoCode(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto, creation time.Time, expiry time.Duration, crypted *crypto.CryptoValue, plain string) error {
- gen, _, err := secretGenerator(ctx, filter, typ, alg)
+ gen, _, err := secretGenerator(ctx, filter, typ, alg, emptyConfig)
if err != nil {
return err
}
return crypto.VerifyCode(creation, expiry, crypted, plain, gen)
}
-func secretGenerator(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (crypto.Generator, *crypto.GeneratorConfig, error) {
- config, err := secretGeneratorConfig(ctx, filter, typ)
+func secretGenerator(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto, defaultConfig *crypto.GeneratorConfig) (crypto.Generator, *crypto.GeneratorConfig, error) {
+ config, err := secretGeneratorConfigWithDefault(ctx, filter, typ, defaultConfig)
if err != nil {
return nil, nil, err
}
@@ -58,26 +66,10 @@ func secretGenerator(ctx context.Context, filter preparation.FilterToQueryReduce
}
func secretGeneratorConfig(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType) (*crypto.GeneratorConfig, error) {
- wm := NewInstanceSecretGeneratorConfigWriteModel(ctx, typ)
- events, err := filter(ctx, wm.Query())
- if err != nil {
- return nil, err
- }
- wm.AppendEvents(events...)
- if err := wm.Reduce(); err != nil {
- return nil, err
- }
- return &crypto.GeneratorConfig{
- Length: wm.Length,
- Expiry: wm.Expiry,
- IncludeLowerLetters: wm.IncludeLowerLetters,
- IncludeUpperLetters: wm.IncludeUpperLetters,
- IncludeDigits: wm.IncludeDigits,
- IncludeSymbols: wm.IncludeSymbols,
- }, nil
+ return secretGeneratorConfigWithDefault(ctx, filter, typ, emptyConfig)
}
-func secretGeneratorConfigWithDefault(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, defaultGenerator *crypto.GeneratorConfig) (*crypto.GeneratorConfig, error) {
+func secretGeneratorConfigWithDefault(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, defaultConfig *crypto.GeneratorConfig) (*crypto.GeneratorConfig, error) {
wm := NewInstanceSecretGeneratorConfigWriteModel(ctx, typ)
events, err := filter(ctx, wm.Query())
if err != nil {
@@ -88,7 +80,7 @@ func secretGeneratorConfigWithDefault(ctx context.Context, filter preparation.Fi
return nil, err
}
if wm.State != domain.SecretGeneratorStateActive {
- return defaultGenerator, nil
+ return defaultConfig, nil
}
return &crypto.GeneratorConfig{
Length: wm.Length,
diff --git a/internal/command/crypto_test.go b/internal/command/crypto_test.go
index 66c2c63c5c..33dca9ec37 100644
--- a/internal/command/crypto_test.go
+++ b/internal/command/crypto_test.go
@@ -33,6 +33,21 @@ func mockCode(code string, exp time.Duration) cryptoCodeFunc {
}
}
+func mockCodeWithDefault(code string, exp time.Duration) cryptoCodeWithDefaultFunc {
+ return func(ctx context.Context, filter preparation.FilterToQueryReducer, _ domain.SecretGeneratorType, alg crypto.Crypto, _ *crypto.GeneratorConfig) (*CryptoCode, error) {
+ return &CryptoCode{
+ Crypted: &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte(code),
+ },
+ Plain: code,
+ Expiry: exp,
+ }, nil
+ }
+}
+
var (
testGeneratorConfig = crypto.GeneratorConfig{
Length: 12,
@@ -175,8 +190,9 @@ func Test_verifyCryptoCode(t *testing.T) {
func Test_secretGenerator(t *testing.T) {
type args struct {
- typ domain.SecretGeneratorType
- alg crypto.Crypto
+ typ domain.SecretGeneratorType
+ alg crypto.Crypto
+ defaultConfig *crypto.GeneratorConfig
}
tests := []struct {
name string
@@ -190,8 +206,9 @@ func Test_secretGenerator(t *testing.T) {
name: "filter config error",
eventsore: eventstoreExpect(t, expectFilterError(io.ErrClosedPipe)),
args: args{
- typ: domain.SecretGeneratorTypeVerifyEmailCode,
- alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
+ typ: domain.SecretGeneratorTypeVerifyEmailCode,
+ alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
+ defaultConfig: emptyConfig,
},
wantErr: io.ErrClosedPipe,
},
@@ -201,8 +218,9 @@ func Test_secretGenerator(t *testing.T) {
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
)),
args: args{
- typ: domain.SecretGeneratorTypeVerifyEmailCode,
- alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
+ typ: domain.SecretGeneratorTypeVerifyEmailCode,
+ alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
+ defaultConfig: emptyConfig,
},
want: crypto.NewHashGenerator(testGeneratorConfig, crypto.CreateMockHashAlg(gomock.NewController(t))),
wantConf: &testGeneratorConfig,
@@ -213,8 +231,31 @@ func Test_secretGenerator(t *testing.T) {
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
)),
args: args{
- typ: domain.SecretGeneratorTypeVerifyEmailCode,
- alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
+ typ: domain.SecretGeneratorTypeVerifyEmailCode,
+ alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
+ defaultConfig: emptyConfig,
+ },
+ want: crypto.NewEncryptionGenerator(testGeneratorConfig, crypto.CreateMockEncryptionAlg(gomock.NewController(t))),
+ wantConf: &testGeneratorConfig,
+ },
+ {
+ name: "hash generator with default config",
+ eventsore: eventstoreExpect(t, expectFilter()),
+ args: args{
+ typ: domain.SecretGeneratorTypeVerifyEmailCode,
+ alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
+ defaultConfig: &testGeneratorConfig,
+ },
+ want: crypto.NewHashGenerator(testGeneratorConfig, crypto.CreateMockHashAlg(gomock.NewController(t))),
+ wantConf: &testGeneratorConfig,
+ },
+ {
+ name: "encryption generator with default config",
+ eventsore: eventstoreExpect(t, expectFilter()),
+ args: args{
+ typ: domain.SecretGeneratorTypeVerifyEmailCode,
+ alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
+ defaultConfig: &testGeneratorConfig,
},
want: crypto.NewEncryptionGenerator(testGeneratorConfig, crypto.CreateMockEncryptionAlg(gomock.NewController(t))),
wantConf: &testGeneratorConfig,
@@ -225,15 +266,16 @@ func Test_secretGenerator(t *testing.T) {
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
)),
args: args{
- typ: domain.SecretGeneratorTypeVerifyEmailCode,
- alg: nil,
+ typ: domain.SecretGeneratorTypeVerifyEmailCode,
+ alg: nil,
+ defaultConfig: emptyConfig,
},
wantErr: errors.ThrowInternalf(nil, "COMMA-RreV6", "Errors.Internal unsupported crypto algorithm type %T", nil),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- got, gotConf, err := secretGenerator(context.Background(), tt.eventsore.Filter, tt.args.typ, tt.args.alg)
+ got, gotConf, err := secretGenerator(context.Background(), tt.eventsore.Filter, tt.args.typ, tt.args.alg, tt.args.defaultConfig)
require.ErrorIs(t, err, tt.wantErr)
assert.IsType(t, tt.want, got)
assert.Equal(t, tt.wantConf, gotConf)
diff --git a/internal/command/session.go b/internal/command/session.go
index fead616c2f..a58c5d2d3a 100644
--- a/internal/command/session.go
+++ b/internal/command/session.go
@@ -33,6 +33,8 @@ type SessionCommands struct {
hasher *crypto.PasswordHasher
intentAlg crypto.EncryptionAlgorithm
totpAlg crypto.EncryptionAlgorithm
+ otpAlg crypto.EncryptionAlgorithm
+ createCode cryptoCodeWithDefaultFunc
createToken func(sessionID string) (id string, token string, err error)
now func() time.Time
}
@@ -45,6 +47,8 @@ func (c *Commands) NewSessionCommands(cmds []SessionCommand, session *SessionWri
hasher: c.userPasswordHasher,
intentAlg: c.idpConfigEncryption,
totpAlg: c.multifactors.OTP.CryptoMFA,
+ otpAlg: c.userEncryption,
+ createCode: c.newCodeWithDefault,
createToken: c.sessionTokenCreator,
now: time.Now,
}
@@ -204,6 +208,22 @@ func (s *SessionCommands) TOTPChecked(ctx context.Context, checkedAt time.Time)
s.eventCommands = append(s.eventCommands, session.NewTOTPCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt))
}
+func (s *SessionCommands) OTPSMSChallenged(ctx context.Context, code *crypto.CryptoValue, expiry time.Duration, returnCode bool) {
+ s.eventCommands = append(s.eventCommands, session.NewOTPSMSChallengedEvent(ctx, s.sessionWriteModel.aggregate, code, expiry, returnCode))
+}
+
+func (s *SessionCommands) OTPSMSChecked(ctx context.Context, checkedAt time.Time) {
+ s.eventCommands = append(s.eventCommands, session.NewOTPSMSCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt))
+}
+
+func (s *SessionCommands) OTPEmailChallenged(ctx context.Context, code *crypto.CryptoValue, expiry time.Duration, returnCode bool, urlTmpl string) {
+ s.eventCommands = append(s.eventCommands, session.NewOTPEmailChallengedEvent(ctx, s.sessionWriteModel.aggregate, code, expiry, returnCode, urlTmpl))
+}
+
+func (s *SessionCommands) OTPEmailChecked(ctx context.Context, checkedAt time.Time) {
+ s.eventCommands = append(s.eventCommands, session.NewOTPEmailCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt))
+}
+
func (s *SessionCommands) SetToken(ctx context.Context, tokenID string) {
s.eventCommands = append(s.eventCommands, session.NewTokenSetEvent(ctx, s.sessionWriteModel.aggregate, tokenID))
}
diff --git a/internal/command/session_model.go b/internal/command/session_model.go
index 373e5b96b4..0ae23bac7c 100644
--- a/internal/command/session_model.go
+++ b/internal/command/session_model.go
@@ -3,6 +3,7 @@ package command
import (
"time"
+ "github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/session"
@@ -15,6 +16,12 @@ type WebAuthNChallengeModel struct {
RPID string
}
+type OTPCode struct {
+ Code *crypto.CryptoValue
+ Expiry time.Duration
+ CreationDate time.Time
+}
+
func (p *WebAuthNChallengeModel) WebAuthNLogin(human *domain.Human, credentialAssertionData []byte) *domain.WebAuthNLogin {
return &domain.WebAuthNLogin{
ObjectRoot: human.ObjectRoot,
@@ -36,11 +43,15 @@ type SessionWriteModel struct {
IntentCheckedAt time.Time
WebAuthNCheckedAt time.Time
TOTPCheckedAt time.Time
+ OTPSMSCheckedAt time.Time
+ OTPEmailCheckedAt time.Time
WebAuthNUserVerified bool
Metadata map[string][]byte
State domain.SessionState
- WebAuthNChallenge *WebAuthNChallengeModel
+ WebAuthNChallenge *WebAuthNChallengeModel
+ OTPSMSCodeChallenge *OTPCode
+ OTPEmailCodeChallenge *OTPCode
aggregate *eventstore.Aggregate
}
@@ -73,6 +84,14 @@ func (wm *SessionWriteModel) Reduce() error {
wm.reduceWebAuthNChecked(e)
case *session.TOTPCheckedEvent:
wm.reduceTOTPChecked(e)
+ case *session.OTPSMSChallengedEvent:
+ wm.reduceOTPSMSChallenged(e)
+ case *session.OTPSMSCheckedEvent:
+ wm.reduceOTPSMSChecked(e)
+ case *session.OTPEmailChallengedEvent:
+ wm.reduceOTPEmailChallenged(e)
+ case *session.OTPEmailCheckedEvent:
+ wm.reduceOTPEmailChecked(e)
case *session.TokenSetEvent:
wm.reduceTokenSet(e)
case *session.TerminateEvent:
@@ -95,6 +114,10 @@ func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder {
session.WebAuthNChallengedType,
session.WebAuthNCheckedType,
session.TOTPCheckedType,
+ session.OTPSMSChallengedType,
+ session.OTPSMSCheckedType,
+ session.OTPEmailChallengedType,
+ session.OTPEmailCheckedType,
session.TokenSetType,
session.MetadataSetType,
session.TerminateType,
@@ -143,6 +166,32 @@ func (wm *SessionWriteModel) reduceTOTPChecked(e *session.TOTPCheckedEvent) {
wm.TOTPCheckedAt = e.CheckedAt
}
+func (wm *SessionWriteModel) reduceOTPSMSChallenged(e *session.OTPSMSChallengedEvent) {
+ wm.OTPSMSCodeChallenge = &OTPCode{
+ Code: e.Code,
+ Expiry: e.Expiry,
+ CreationDate: e.CreationDate(),
+ }
+}
+
+func (wm *SessionWriteModel) reduceOTPSMSChecked(e *session.OTPSMSCheckedEvent) {
+ wm.OTPSMSCodeChallenge = nil
+ wm.OTPSMSCheckedAt = e.CheckedAt
+}
+
+func (wm *SessionWriteModel) reduceOTPEmailChallenged(e *session.OTPEmailChallengedEvent) {
+ wm.OTPEmailCodeChallenge = &OTPCode{
+ Code: e.Code,
+ Expiry: e.Expiry,
+ CreationDate: e.CreationDate(),
+ }
+}
+
+func (wm *SessionWriteModel) reduceOTPEmailChecked(e *session.OTPEmailCheckedEvent) {
+ wm.OTPEmailCodeChallenge = nil
+ wm.OTPEmailCheckedAt = e.CheckedAt
+}
+
func (wm *SessionWriteModel) reduceTokenSet(e *session.TokenSetEvent) {
wm.TokenID = e.TokenID
}
@@ -159,7 +208,8 @@ func (wm *SessionWriteModel) AuthenticationTime() time.Time {
wm.WebAuthNCheckedAt,
wm.TOTPCheckedAt,
wm.IntentCheckedAt,
- // TODO: add OTP (sms and email) check https://github.com/zitadel/zitadel/issues/6224
+ wm.OTPSMSCheckedAt,
+ wm.OTPEmailCheckedAt,
} {
if check.After(authTime) {
authTime = check
@@ -187,14 +237,11 @@ func (wm *SessionWriteModel) AuthMethodTypes() []domain.UserAuthMethodType {
if !wm.TOTPCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeTOTP)
}
- // 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)
- }
- */
+ if !wm.OTPSMSCheckedAt.IsZero() {
+ types = append(types, domain.UserAuthMethodTypeOTPSMS)
+ }
+ if !wm.OTPEmailCheckedAt.IsZero() {
+ types = append(types, domain.UserAuthMethodTypeOTPEmail)
+ }
return types
}
diff --git a/internal/command/session_otp.go b/internal/command/session_otp.go
new file mode 100644
index 0000000000..eecf47f90b
--- /dev/null
+++ b/internal/command/session_otp.go
@@ -0,0 +1,148 @@
+package command
+
+import (
+ "context"
+ "io"
+
+ "golang.org/x/text/language"
+
+ "github.com/zitadel/zitadel/internal/crypto"
+ "github.com/zitadel/zitadel/internal/domain"
+ caos_errs "github.com/zitadel/zitadel/internal/errors"
+ "github.com/zitadel/zitadel/internal/repository/session"
+)
+
+func (c *Commands) CreateOTPSMSChallengeReturnCode(dst *string) SessionCommand {
+ return c.createOTPSMSChallenge(true, dst)
+}
+
+func (c *Commands) CreateOTPSMSChallenge() SessionCommand {
+ return c.createOTPSMSChallenge(false, nil)
+}
+
+func (c *Commands) createOTPSMSChallenge(returnCode bool, dst *string) SessionCommand {
+ return func(ctx context.Context, cmd *SessionCommands) error {
+ if cmd.sessionWriteModel.UserID == "" {
+ return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-JKL3g", "Errors.User.UserIDMissing")
+ }
+ writeModel := NewHumanOTPSMSWriteModel(cmd.sessionWriteModel.UserID, "")
+ if err := cmd.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil {
+ return err
+ }
+ if !writeModel.OTPAdded() {
+ return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-BJ2g3", "Errors.User.MFA.OTP.NotReady")
+ }
+ code, err := cmd.createCode(ctx, cmd.eventstore.Filter, domain.SecretGeneratorTypeOTPSMS, cmd.otpAlg, c.defaultSecretGenerators.OTPSMS)
+ if err != nil {
+ return err
+ }
+ if returnCode {
+ *dst = code.Plain
+ }
+ cmd.OTPSMSChallenged(ctx, code.Crypted, code.Expiry, returnCode)
+ return nil
+ }
+}
+
+func (c *Commands) OTPSMSSent(ctx context.Context, sessionID, resourceOwner string) error {
+ sessionWriteModel := NewSessionWriteModel(sessionID, resourceOwner)
+ err := c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel)
+ if err != nil {
+ return err
+ }
+ if sessionWriteModel.OTPSMSCodeChallenge == nil {
+ return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-G3t31", "Errors.User.Code.NotFound")
+ }
+ return c.pushAppendAndReduce(ctx, sessionWriteModel,
+ session.NewOTPSMSSentEvent(ctx, &session.NewAggregate(sessionID, sessionWriteModel.ResourceOwner).Aggregate),
+ )
+}
+
+func (c *Commands) CreateOTPEmailChallengeURLTemplate(urlTmpl string) (SessionCommand, error) {
+ if err := domain.RenderOTPEmailURLTemplate(io.Discard, urlTmpl, "code", "userID", "loginName", "displayName", language.English); err != nil {
+ return nil, err
+ }
+ return c.createOTPEmailChallenge(false, urlTmpl, nil), nil
+}
+
+func (c *Commands) CreateOTPEmailChallengeReturnCode(dst *string) SessionCommand {
+ return c.createOTPEmailChallenge(true, "", dst)
+}
+
+func (c *Commands) CreateOTPEmailChallenge() SessionCommand {
+ return c.createOTPEmailChallenge(false, "", nil)
+}
+
+func (c *Commands) createOTPEmailChallenge(returnCode bool, urlTmpl string, dst *string) SessionCommand {
+ return func(ctx context.Context, cmd *SessionCommands) error {
+ if cmd.sessionWriteModel.UserID == "" {
+ return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-JK3gp", "Errors.User.UserIDMissing")
+ }
+ writeModel := NewHumanOTPEmailWriteModel(cmd.sessionWriteModel.UserID, "")
+ if err := cmd.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil {
+ return err
+ }
+ if !writeModel.OTPAdded() {
+ return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-JKLJ3", "Errors.User.MFA.OTP.NotReady")
+ }
+ code, err := cmd.createCode(ctx, cmd.eventstore.Filter, domain.SecretGeneratorTypeOTPEmail, cmd.otpAlg, c.defaultSecretGenerators.OTPEmail)
+ if err != nil {
+ return err
+ }
+ if returnCode {
+ *dst = code.Plain
+ }
+ cmd.OTPEmailChallenged(ctx, code.Crypted, code.Expiry, returnCode, urlTmpl)
+ return nil
+ }
+}
+
+func (c *Commands) OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error {
+ sessionWriteModel := NewSessionWriteModel(sessionID, resourceOwner)
+ err := c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel)
+ if err != nil {
+ return err
+ }
+ if sessionWriteModel.OTPEmailCodeChallenge == nil {
+ return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-SLr02", "Errors.User.Code.NotFound")
+ }
+ return c.pushAppendAndReduce(ctx, sessionWriteModel,
+ session.NewOTPEmailSentEvent(ctx, &session.NewAggregate(sessionID, sessionWriteModel.ResourceOwner).Aggregate),
+ )
+}
+
+func CheckOTPSMS(code string) SessionCommand {
+ return func(ctx context.Context, cmd *SessionCommands) (err error) {
+ if cmd.sessionWriteModel.UserID == "" {
+ return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-VDrh3", "Errors.User.UserIDMissing")
+ }
+ challenge := cmd.sessionWriteModel.OTPSMSCodeChallenge
+ if challenge == nil {
+ return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-SF3tv", "Errors.User.Code.NotFound")
+ }
+ err = crypto.VerifyCodeWithAlgorithm(challenge.CreationDate, challenge.Expiry, challenge.Code, code, cmd.otpAlg)
+ if err != nil {
+ return err
+ }
+ cmd.OTPSMSChecked(ctx, cmd.now())
+ return nil
+ }
+}
+
+func CheckOTPEmail(code string) SessionCommand {
+ return func(ctx context.Context, cmd *SessionCommands) (err error) {
+ if cmd.sessionWriteModel.UserID == "" {
+ return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-ejo2w", "Errors.User.UserIDMissing")
+ }
+ challenge := cmd.sessionWriteModel.OTPEmailCodeChallenge
+ if challenge == nil {
+ return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-zF3g3", "Errors.User.Code.NotFound")
+ }
+ err = crypto.VerifyCodeWithAlgorithm(challenge.CreationDate, challenge.Expiry, challenge.Code, code, cmd.otpAlg)
+ if err != nil {
+ return err
+ }
+ cmd.OTPEmailChecked(ctx, cmd.now())
+ return nil
+ }
+}
diff --git a/internal/command/session_otp_test.go b/internal/command/session_otp_test.go
new file mode 100644
index 0000000000..9cf957945f
--- /dev/null
+++ b/internal/command/session_otp_test.go
@@ -0,0 +1,951 @@
+package command
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
+
+ "github.com/zitadel/zitadel/internal/crypto"
+ "github.com/zitadel/zitadel/internal/domain"
+ caos_errs "github.com/zitadel/zitadel/internal/errors"
+ "github.com/zitadel/zitadel/internal/eventstore"
+ "github.com/zitadel/zitadel/internal/repository/session"
+ "github.com/zitadel/zitadel/internal/repository/user"
+)
+
+func TestCommands_CreateOTPSMSChallengeReturnCode(t *testing.T) {
+ type fields struct {
+ userID string
+ eventstore func(*testing.T) *eventstore.Eventstore
+ createCode cryptoCodeWithDefaultFunc
+ }
+ type res struct {
+ err error
+ returnCode string
+ commands []eventstore.Command
+ }
+ tests := []struct {
+ name string
+ fields fields
+ res res
+ }{
+ {
+ name: "userID missing, precondition error",
+ fields: fields{
+ userID: "",
+ eventstore: expectEventstore(),
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-JKL3g", "Errors.User.UserIDMissing"),
+ },
+ },
+ {
+ name: "otp not ready, precondition error",
+ fields: fields{
+ userID: "userID",
+ eventstore: expectEventstore(
+ expectFilter(),
+ ),
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-BJ2g3", "Errors.User.MFA.OTP.NotReady"),
+ },
+ },
+ {
+ name: "generate code",
+ fields: fields{
+ userID: "userID",
+ eventstore: expectEventstore(
+ expectFilter(
+ eventFromEventPusher(
+ user.NewHumanOTPSMSAddedEvent(context.Background(), &user.NewAggregate("userID", "org").Aggregate),
+ ),
+ ),
+ ),
+ createCode: mockCodeWithDefault("1234567", 5*time.Minute),
+ },
+ res: res{
+ returnCode: "1234567",
+ commands: []eventstore.Command{
+ session.NewOTPSMSChallengedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("1234567"),
+ },
+ 5*time.Minute,
+ true,
+ ),
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &Commands{
+ // config will not be actively used for the test (is only for default),
+ // but not providing it would result in a nil pointer
+ defaultSecretGenerators: &SecretGenerators{
+ OTPSMS: emptyConfig,
+ },
+ }
+ var dst string
+ cmd := c.CreateOTPSMSChallengeReturnCode(&dst)
+
+ sessionModel := &SessionWriteModel{
+ UserID: tt.fields.userID,
+ UserCheckedAt: testNow,
+ State: domain.SessionStateActive,
+ aggregate: &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ }
+ cmds := &SessionCommands{
+ sessionCommands: []SessionCommand{cmd},
+ sessionWriteModel: sessionModel,
+ eventstore: tt.fields.eventstore(t),
+ createCode: tt.fields.createCode,
+ now: time.Now,
+ }
+
+ err := cmd(context.Background(), cmds)
+ assert.ErrorIs(t, err, tt.res.err)
+ assert.Equal(t, tt.res.returnCode, dst)
+ assert.Equal(t, tt.res.commands, cmds.eventCommands)
+ })
+ }
+}
+
+func TestCommands_CreateOTPSMSChallenge(t *testing.T) {
+ type fields struct {
+ userID string
+ eventstore func(*testing.T) *eventstore.Eventstore
+ createCode cryptoCodeWithDefaultFunc
+ }
+ type res struct {
+ err error
+ commands []eventstore.Command
+ }
+ tests := []struct {
+ name string
+ fields fields
+ res res
+ }{
+ {
+ name: "userID missing, precondition error",
+ fields: fields{
+ userID: "",
+ eventstore: expectEventstore(),
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-JKL3g", "Errors.User.UserIDMissing"),
+ },
+ },
+ {
+ name: "otp not ready, precondition error",
+ fields: fields{
+ userID: "userID",
+ eventstore: expectEventstore(
+ expectFilter(),
+ ),
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-BJ2g3", "Errors.User.MFA.OTP.NotReady"),
+ },
+ },
+ {
+ name: "generate code",
+ fields: fields{
+ userID: "userID",
+ eventstore: expectEventstore(
+ expectFilter(
+ eventFromEventPusher(
+ user.NewHumanOTPSMSAddedEvent(context.Background(), &user.NewAggregate("userID", "org").Aggregate),
+ ),
+ ),
+ ),
+ createCode: mockCodeWithDefault("1234567", 5*time.Minute),
+ },
+ res: res{
+ commands: []eventstore.Command{
+ session.NewOTPSMSChallengedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("1234567"),
+ },
+ 5*time.Minute,
+ false,
+ ),
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &Commands{
+ // config will not be actively used for the test (is only for default),
+ // but not providing it would result in a nil pointer
+ defaultSecretGenerators: &SecretGenerators{
+ OTPSMS: emptyConfig,
+ },
+ }
+
+ cmd := c.CreateOTPSMSChallenge()
+
+ sessionModel := &SessionWriteModel{
+ UserID: tt.fields.userID,
+ UserCheckedAt: testNow,
+ State: domain.SessionStateActive,
+ aggregate: &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ }
+ cmds := &SessionCommands{
+ sessionCommands: []SessionCommand{cmd},
+ sessionWriteModel: sessionModel,
+ eventstore: tt.fields.eventstore(t),
+ createCode: tt.fields.createCode,
+ now: time.Now,
+ }
+
+ err := cmd(context.Background(), cmds)
+ assert.ErrorIs(t, err, tt.res.err)
+ assert.Equal(t, tt.res.commands, cmds.eventCommands)
+ })
+ }
+}
+
+func TestCommands_OTPSMSSent(t *testing.T) {
+ type fields struct {
+ eventstore func(*testing.T) *eventstore.Eventstore
+ }
+ type args struct {
+ ctx context.Context
+ sessionID string
+ resourceOwner string
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ wantErr error
+ }{
+ {
+ name: "not challenged, precondition error",
+ fields: fields{
+ eventstore: expectEventstore(
+ expectFilter(),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ sessionID: "sessionID",
+ resourceOwner: "instanceID",
+ },
+ wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-G3t31", "Errors.User.Code.NotFound"),
+ },
+ {
+ name: "challenged and sent",
+ fields: fields{
+ eventstore: expectEventstore(
+ expectFilter(
+ eventFromEventPusher(
+ session.NewOTPSMSChallengedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("1234567"),
+ },
+ 5*time.Minute,
+ false,
+ ),
+ ),
+ ),
+ expectPush(
+ eventPusherToEvents(
+ session.NewOTPSMSSentEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate),
+ ),
+ ),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ sessionID: "sessionID",
+ resourceOwner: "instanceID",
+ },
+ wantErr: nil,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &Commands{
+ eventstore: tt.fields.eventstore(t),
+ }
+ err := c.OTPSMSSent(tt.args.ctx, tt.args.sessionID, tt.args.resourceOwner)
+ assert.ErrorIs(t, err, tt.wantErr)
+ })
+ }
+}
+
+func TestCommands_CreateOTPEmailChallengeURLTemplate(t *testing.T) {
+ type fields struct {
+ userID string
+ eventstore func(*testing.T) *eventstore.Eventstore
+ createCode cryptoCodeWithDefaultFunc
+ }
+ type args struct {
+ urlTmpl string
+ }
+ type res struct {
+ templateError error
+ err error
+ commands []eventstore.Command
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ res res
+ }{
+ {
+ name: "invalid template, precondition error",
+ args: args{
+ urlTmpl: "https://example.com/mfa/email?userID={{.UserID}}&code={{.InvalidField}}",
+ },
+ fields: fields{
+ eventstore: expectEventstore(),
+ },
+ res: res{
+ templateError: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-ieYa7", "Errors.User.InvalidURLTemplate"),
+ },
+ },
+ {
+ name: "userID missing, precondition error",
+ args: args{
+ urlTmpl: "https://example.com/mfa/email?userID={{.UserID}}&code={{.Code}}&lang={{.PreferredLanguage}}",
+ },
+ fields: fields{
+ eventstore: expectEventstore(),
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-JK3gp", "Errors.User.UserIDMissing"),
+ },
+ },
+ {
+ name: "otp not ready, precondition error",
+ args: args{
+ urlTmpl: "https://example.com/mfa/email?userID={{.UserID}}&code={{.Code}}&lang={{.PreferredLanguage}}",
+ },
+ fields: fields{
+ userID: "userID",
+ eventstore: expectEventstore(
+ expectFilter(),
+ ),
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-JKLJ3", "Errors.User.MFA.OTP.NotReady"),
+ },
+ },
+ {
+ name: "generate code",
+ args: args{
+ urlTmpl: "https://example.com/mfa/email?userID={{.UserID}}&code={{.Code}}&lang={{.PreferredLanguage}}",
+ },
+ fields: fields{
+ userID: "userID",
+ eventstore: expectEventstore(
+ expectFilter(
+ eventFromEventPusher(
+ user.NewHumanOTPEmailAddedEvent(context.Background(), &user.NewAggregate("userID", "org").Aggregate),
+ ),
+ ),
+ ),
+ createCode: mockCodeWithDefault("1234567", 5*time.Minute),
+ },
+ res: res{
+ commands: []eventstore.Command{
+ session.NewOTPEmailChallengedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("1234567"),
+ },
+ 5*time.Minute,
+ false,
+ "https://example.com/mfa/email?userID={{.UserID}}&code={{.Code}}&lang={{.PreferredLanguage}}",
+ ),
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &Commands{
+ // config will not be actively used for the test (is only for default),
+ // but not providing it would result in a nil pointer
+ defaultSecretGenerators: &SecretGenerators{
+ OTPEmail: emptyConfig,
+ },
+ }
+
+ cmd, err := c.CreateOTPEmailChallengeURLTemplate(tt.args.urlTmpl)
+ assert.ErrorIs(t, err, tt.res.templateError)
+ if tt.res.templateError != nil {
+ return
+ }
+
+ sessionModel := &SessionWriteModel{
+ UserID: tt.fields.userID,
+ UserCheckedAt: testNow,
+ State: domain.SessionStateActive,
+ aggregate: &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ }
+ cmds := &SessionCommands{
+ sessionCommands: []SessionCommand{cmd},
+ sessionWriteModel: sessionModel,
+ eventstore: tt.fields.eventstore(t),
+ createCode: tt.fields.createCode,
+ now: time.Now,
+ }
+
+ err = cmd(context.Background(), cmds)
+ assert.ErrorIs(t, err, tt.res.err)
+ assert.Equal(t, tt.res.commands, cmds.eventCommands)
+ })
+ }
+}
+
+func TestCommands_CreateOTPEmailChallengeReturnCode(t *testing.T) {
+ type fields struct {
+ userID string
+ eventstore func(*testing.T) *eventstore.Eventstore
+ createCode cryptoCodeWithDefaultFunc
+ }
+ type res struct {
+ err error
+ returnCode string
+ commands []eventstore.Command
+ }
+ tests := []struct {
+ name string
+ fields fields
+ res res
+ }{
+ {
+ name: "userID missing, precondition error",
+ fields: fields{
+ eventstore: expectEventstore(),
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-JK3gp", "Errors.User.UserIDMissing"),
+ },
+ },
+ {
+ name: "otp not ready, precondition error",
+ fields: fields{
+ userID: "userID",
+ eventstore: expectEventstore(
+ expectFilter(),
+ ),
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-JKLJ3", "Errors.User.MFA.OTP.NotReady"),
+ },
+ },
+ {
+ name: "generate code",
+ fields: fields{
+ userID: "userID",
+ eventstore: expectEventstore(
+ expectFilter(
+ eventFromEventPusher(
+ user.NewHumanOTPEmailAddedEvent(context.Background(), &user.NewAggregate("userID", "org").Aggregate),
+ ),
+ ),
+ ),
+ createCode: mockCodeWithDefault("1234567", 5*time.Minute),
+ },
+ res: res{
+ returnCode: "1234567",
+ commands: []eventstore.Command{
+ session.NewOTPEmailChallengedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("1234567"),
+ },
+ 5*time.Minute,
+ true,
+ "",
+ ),
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &Commands{
+ // config will not be actively used for the test (is only for default),
+ // but not providing it would result in a nil pointer
+ defaultSecretGenerators: &SecretGenerators{
+ OTPEmail: emptyConfig,
+ },
+ }
+ var dst string
+ cmd := c.CreateOTPEmailChallengeReturnCode(&dst)
+
+ sessionModel := &SessionWriteModel{
+ UserID: tt.fields.userID,
+ UserCheckedAt: testNow,
+ State: domain.SessionStateActive,
+ aggregate: &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ }
+ cmds := &SessionCommands{
+ sessionCommands: []SessionCommand{cmd},
+ sessionWriteModel: sessionModel,
+ eventstore: tt.fields.eventstore(t),
+ createCode: tt.fields.createCode,
+ now: time.Now,
+ }
+
+ err := cmd(context.Background(), cmds)
+ assert.ErrorIs(t, err, tt.res.err)
+ assert.Equal(t, tt.res.returnCode, dst)
+ assert.Equal(t, tt.res.commands, cmds.eventCommands)
+ })
+ }
+}
+
+func TestCommands_CreateOTPEmailChallenge(t *testing.T) {
+ type fields struct {
+ userID string
+ eventstore func(*testing.T) *eventstore.Eventstore
+ createCode cryptoCodeWithDefaultFunc
+ }
+ type res struct {
+ err error
+ commands []eventstore.Command
+ }
+ tests := []struct {
+ name string
+ fields fields
+ res res
+ }{
+ {
+ name: "userID missing, precondition error",
+ fields: fields{
+ eventstore: expectEventstore(),
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-JK3gp", "Errors.User.UserIDMissing"),
+ },
+ },
+ {
+ name: "otp not ready, precondition error",
+ fields: fields{
+ userID: "userID",
+ eventstore: expectEventstore(
+ expectFilter(),
+ ),
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-JKLJ3", "Errors.User.MFA.OTP.NotReady"),
+ },
+ },
+ {
+ name: "generate code",
+ fields: fields{
+ userID: "userID",
+ eventstore: expectEventstore(
+ expectFilter(
+ eventFromEventPusher(
+ user.NewHumanOTPEmailAddedEvent(context.Background(), &user.NewAggregate("userID", "org").Aggregate),
+ ),
+ ),
+ ),
+ createCode: mockCodeWithDefault("1234567", 5*time.Minute),
+ },
+ res: res{
+ commands: []eventstore.Command{
+ session.NewOTPEmailChallengedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("1234567"),
+ },
+ 5*time.Minute,
+ false,
+ "",
+ ),
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &Commands{
+ // config will not be actively used for the test (is only for default),
+ // but not providing it would result in a nil pointer
+ defaultSecretGenerators: &SecretGenerators{
+ OTPEmail: emptyConfig,
+ },
+ }
+
+ cmd := c.CreateOTPEmailChallenge()
+
+ sessionModel := &SessionWriteModel{
+ UserID: tt.fields.userID,
+ UserCheckedAt: testNow,
+ State: domain.SessionStateActive,
+ aggregate: &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ }
+ cmds := &SessionCommands{
+ sessionCommands: []SessionCommand{cmd},
+ sessionWriteModel: sessionModel,
+ eventstore: tt.fields.eventstore(t),
+ createCode: tt.fields.createCode,
+ now: time.Now,
+ }
+
+ err := cmd(context.Background(), cmds)
+ assert.ErrorIs(t, err, tt.res.err)
+ assert.Equal(t, tt.res.commands, cmds.eventCommands)
+ })
+ }
+}
+
+func TestCommands_OTPEmailSent(t *testing.T) {
+ type fields struct {
+ eventstore func(*testing.T) *eventstore.Eventstore
+ }
+ type args struct {
+ ctx context.Context
+ sessionID string
+ resourceOwner string
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ wantErr error
+ }{
+ {
+ name: "not challenged, precondition error",
+ fields: fields{
+ eventstore: expectEventstore(
+ expectFilter(),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ sessionID: "sessionID",
+ resourceOwner: "instanceID",
+ },
+ wantErr: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-SLr02", "Errors.User.Code.NotFound"),
+ },
+ {
+ name: "challenged and sent",
+ fields: fields{
+ eventstore: expectEventstore(
+ expectFilter(
+ eventFromEventPusher(
+ session.NewOTPEmailChallengedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("1234567"),
+ },
+ 5*time.Minute,
+ false,
+ "",
+ ),
+ ),
+ ),
+ expectPush(
+ eventPusherToEvents(
+ session.NewOTPEmailSentEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate),
+ ),
+ ),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ sessionID: "sessionID",
+ resourceOwner: "instanceID",
+ },
+ wantErr: nil,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &Commands{
+ eventstore: tt.fields.eventstore(t),
+ }
+ err := c.OTPEmailSent(tt.args.ctx, tt.args.sessionID, tt.args.resourceOwner)
+ assert.ErrorIs(t, err, tt.wantErr)
+ })
+ }
+}
+
+func TestCheckOTPSMS(t *testing.T) {
+ type fields struct {
+ eventstore func(*testing.T) *eventstore.Eventstore
+ userID string
+ otpCodeChallenge *OTPCode
+ otpAlg crypto.EncryptionAlgorithm
+ }
+ type args struct {
+ code string
+ }
+ type res struct {
+ err error
+ commands []eventstore.Command
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ res res
+ }{
+ {
+ name: "missing userID",
+ fields: fields{
+ eventstore: expectEventstore(),
+ userID: "",
+ },
+ args: args{
+ code: "code",
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-VDrh3", "Errors.User.UserIDMissing"),
+ },
+ },
+ {
+ name: "missing challenge",
+ fields: fields{
+ eventstore: expectEventstore(),
+ userID: "userID",
+ otpCodeChallenge: nil,
+ },
+ args: args{
+ code: "code",
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-SF3tv", "Errors.User.Code.NotFound"),
+ },
+ },
+ {
+ name: "invalid code",
+ fields: fields{
+ eventstore: expectEventstore(),
+ userID: "userID",
+ otpCodeChallenge: &OTPCode{
+ Code: &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("code"),
+ },
+ Expiry: 5 * time.Minute,
+ CreationDate: testNow.Add(-10 * time.Minute),
+ },
+ otpAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
+ },
+ args: args{
+ code: "code",
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "CODE-QvUQ4P", "Errors.User.Code.Expired"),
+ },
+ },
+ {
+ name: "check ok",
+ fields: fields{
+ eventstore: expectEventstore(),
+ userID: "userID",
+ otpCodeChallenge: &OTPCode{
+ Code: &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("code"),
+ },
+ Expiry: 5 * time.Minute,
+ CreationDate: testNow,
+ },
+ otpAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
+ },
+ args: args{
+ code: "code",
+ },
+ res: res{
+ commands: []eventstore.Command{
+ session.NewOTPSMSCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ testNow,
+ ),
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cmd := CheckOTPSMS(tt.args.code)
+
+ sessionModel := &SessionWriteModel{
+ UserID: tt.fields.userID,
+ UserCheckedAt: testNow,
+ State: domain.SessionStateActive,
+ OTPSMSCodeChallenge: tt.fields.otpCodeChallenge,
+ aggregate: &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ }
+ cmds := &SessionCommands{
+ sessionCommands: []SessionCommand{cmd},
+ sessionWriteModel: sessionModel,
+ eventstore: tt.fields.eventstore(t),
+ otpAlg: tt.fields.otpAlg,
+ now: func() time.Time {
+ return testNow
+ },
+ }
+
+ err := cmd(context.Background(), cmds)
+ assert.ErrorIs(t, err, tt.res.err)
+ assert.Equal(t, tt.res.commands, cmds.eventCommands)
+ })
+ }
+}
+
+func TestCheckOTPEmail(t *testing.T) {
+ type fields struct {
+ eventstore func(*testing.T) *eventstore.Eventstore
+ userID string
+ otpCodeChallenge *OTPCode
+ otpAlg crypto.EncryptionAlgorithm
+ }
+ type args struct {
+ code string
+ }
+ type res struct {
+ err error
+ commands []eventstore.Command
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ res res
+ }{
+ {
+ name: "missing userID",
+ fields: fields{
+ eventstore: expectEventstore(),
+ userID: "",
+ },
+ args: args{
+ code: "code",
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-ejo2w", "Errors.User.UserIDMissing"),
+ },
+ },
+ {
+ name: "missing challenge",
+ fields: fields{
+ eventstore: expectEventstore(),
+ userID: "userID",
+ otpCodeChallenge: nil,
+ },
+ args: args{
+ code: "code",
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-zF3g3", "Errors.User.Code.NotFound"),
+ },
+ },
+ {
+ name: "invalid code",
+ fields: fields{
+ eventstore: expectEventstore(),
+ userID: "userID",
+ otpCodeChallenge: &OTPCode{
+ Code: &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("code"),
+ },
+ Expiry: 5 * time.Minute,
+ CreationDate: testNow.Add(-10 * time.Minute),
+ },
+ otpAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
+ },
+ args: args{
+ code: "code",
+ },
+ res: res{
+ err: caos_errs.ThrowPreconditionFailed(nil, "CODE-QvUQ4P", "Errors.User.Code.Expired"),
+ },
+ },
+ {
+ name: "check ok",
+ fields: fields{
+ eventstore: expectEventstore(),
+ userID: "userID",
+ otpCodeChallenge: &OTPCode{
+ Code: &crypto.CryptoValue{
+ CryptoType: crypto.TypeEncryption,
+ Algorithm: "enc",
+ KeyID: "id",
+ Crypted: []byte("code"),
+ },
+ Expiry: 5 * time.Minute,
+ CreationDate: testNow,
+ },
+ otpAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
+ },
+ args: args{
+ code: "code",
+ },
+ res: res{
+ commands: []eventstore.Command{
+ session.NewOTPEmailCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ testNow,
+ ),
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cmd := CheckOTPEmail(tt.args.code)
+
+ sessionModel := &SessionWriteModel{
+ UserID: tt.fields.userID,
+ UserCheckedAt: testNow,
+ State: domain.SessionStateActive,
+ OTPEmailCodeChallenge: tt.fields.otpCodeChallenge,
+ aggregate: &session.NewAggregate("sessionID", "instanceID").Aggregate,
+ }
+ cmds := &SessionCommands{
+ sessionCommands: []SessionCommand{cmd},
+ sessionWriteModel: sessionModel,
+ eventstore: tt.fields.eventstore(t),
+ otpAlg: tt.fields.otpAlg,
+ now: func() time.Time {
+ return testNow
+ },
+ }
+
+ err := cmd(context.Background(), cmds)
+ assert.ErrorIs(t, err, tt.res.err)
+ assert.Equal(t, tt.res.commands, cmds.eventCommands)
+ })
+ }
+}
diff --git a/internal/command/user_human_otp.go b/internal/command/user_human_otp.go
index 170dde17a7..d8aac85e5b 100644
--- a/internal/command/user_human_otp.go
+++ b/internal/command/user_human_otp.go
@@ -310,7 +310,6 @@ func (c *Commands) HumanCheckOTPSMS(ctx context.Context, userID, code, resourceO
resourceOwner,
authRequest,
writeModel,
- domain.SecretGeneratorTypeOTPSMS,
succeededEvent,
failedEvent,
)
@@ -431,7 +430,6 @@ func (c *Commands) HumanCheckOTPEmail(ctx context.Context, userID, code, resourc
resourceOwner,
authRequest,
writeModel,
- domain.SecretGeneratorTypeOTPEmail,
succeededEvent,
failedEvent,
)
@@ -497,7 +495,6 @@ func (c *Commands) humanCheckOTP(
userID, code, resourceOwner string,
authRequest *domain.AuthRequest,
writeModelByID func(ctx context.Context, userID string, resourceOwner string) (OTPCodeWriteModel, error),
- secretGeneratorType domain.SecretGeneratorType,
checkSucceededEvent func(ctx context.Context, aggregate *eventstore.Aggregate, info *user.AuthRequestInfo) eventstore.Command,
checkFailedEvent func(ctx context.Context, aggregate *eventstore.Aggregate, info *user.AuthRequestInfo) eventstore.Command,
) error {
diff --git a/internal/domain/session.go b/internal/domain/session.go
index 84a74a8f63..56dda951da 100644
--- a/internal/domain/session.go
+++ b/internal/domain/session.go
@@ -1,5 +1,11 @@
package domain
+import (
+ "io"
+
+ "golang.org/x/text/language"
+)
+
type SessionState int32
const (
@@ -7,3 +13,23 @@ const (
SessionStateActive
SessionStateTerminated
)
+
+type OTPEmailURLData struct {
+ Code string
+ UserID string
+ LoginName string
+ DisplayName string
+ PreferredLanguage language.Tag
+}
+
+// RenderOTPEmailURLTemplate parses and renders tmpl.
+// code, userID, (preferred) loginName, displayName and preferredLanguage are passed into the [OTPEmailURLData].
+func RenderOTPEmailURLTemplate(w io.Writer, tmpl, code, userID, loginName, displayName string, preferredLanguage language.Tag) error {
+ return renderURLTemplate(w, tmpl, &OTPEmailURLData{
+ Code: code,
+ UserID: userID,
+ LoginName: loginName,
+ DisplayName: displayName,
+ PreferredLanguage: preferredLanguage,
+ })
+}
diff --git a/internal/notification/handlers/user_notifier.go b/internal/notification/handlers/user_notifier.go
index d5ad60b464..7b1d407662 100644
--- a/internal/notification/handlers/user_notifier.go
+++ b/internal/notification/handlers/user_notifier.go
@@ -2,9 +2,11 @@ package handlers
import (
"context"
+ "strings"
"time"
"github.com/zitadel/zitadel/internal/api/authz"
+ "github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
@@ -13,7 +15,9 @@ import (
"github.com/zitadel/zitadel/internal/eventstore/handler"
"github.com/zitadel/zitadel/internal/eventstore/handler/crdb"
"github.com/zitadel/zitadel/internal/notification/types"
+ "github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/query/projection"
+ "github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
)
@@ -26,6 +30,7 @@ type userNotifier struct {
commands *command.Commands
queries *NotificationQueries
assetsPrefix func(context.Context) string
+ otpEmailTmpl string
metricSuccessfulDeliveriesEmail,
metricFailedDeliveriesEmail,
metricSuccessfulDeliveriesSMS,
@@ -38,6 +43,7 @@ func NewUserNotifier(
commands *command.Commands,
queries *NotificationQueries,
assetsPrefix func(context.Context) string,
+ otpEmailTmpl string,
metricSuccessfulDeliveriesEmail,
metricFailedDeliveriesEmail,
metricSuccessfulDeliveriesSMS,
@@ -50,6 +56,7 @@ func NewUserNotifier(
p.commands = commands
p.queries = queries
p.assetsPrefix = assetsPrefix
+ p.otpEmailTmpl = otpEmailTmpl
p.metricSuccessfulDeliveriesEmail = metricSuccessfulDeliveriesEmail
p.metricFailedDeliveriesEmail = metricFailedDeliveriesEmail
p.metricSuccessfulDeliveriesSMS = metricSuccessfulDeliveriesSMS
@@ -117,6 +124,19 @@ func (u *userNotifier) reducers() []handler.AggregateReducer {
},
},
},
+ {
+ Aggregate: session.AggregateType,
+ EventRedusers: []handler.EventReducer{
+ {
+ Event: session.OTPSMSChallengedType,
+ Reduce: u.reduceSessionOTPSMSChallenged,
+ },
+ {
+ Event: session.OTPEmailChallengedType,
+ Reduce: u.reduceSessionOTPEmailChallenged,
+ },
+ },
+ },
}
}
@@ -346,25 +366,70 @@ func (u *userNotifier) reduceOTPSMSCodeAdded(event eventstore.Event) (*handler.S
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-ASF3g", "reduce.wrong.event.type %s", user.HumanOTPSMSCodeAddedType)
}
+ return u.reduceOTPSMS(
+ e,
+ e.Code,
+ e.Expiry,
+ e.Aggregate().ID,
+ e.Aggregate().ResourceOwner,
+ u.commands.HumanOTPSMSCodeSent,
+ user.HumanOTPSMSCodeAddedType,
+ user.HumanOTPSMSCodeSentType,
+ )
+}
+
+func (u *userNotifier) reduceSessionOTPSMSChallenged(event eventstore.Event) (*handler.Statement, error) {
+ e, ok := event.(*session.OTPSMSChallengedEvent)
+ if !ok {
+ return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-Sk32L", "reduce.wrong.event.type %s", session.OTPSMSChallengedType)
+ }
+ if e.CodeReturned {
+ return crdb.NewNoOpStatement(e), nil
+ }
ctx := HandlerContext(event.Aggregate())
- alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
- user.HumanOTPSMSCodeAddedType, user.HumanOTPSMSCodeSentType)
+ s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "")
+ if err != nil {
+ return nil, err
+ }
+ return u.reduceOTPSMS(
+ e,
+ e.Code,
+ e.Expiry,
+ s.UserFactor.UserID,
+ s.UserFactor.ResourceOwner,
+ u.commands.OTPSMSSent,
+ session.OTPSMSChallengedType,
+ session.OTPSMSSentType,
+ )
+}
+
+func (u *userNotifier) reduceOTPSMS(
+ event eventstore.Event,
+ code *crypto.CryptoValue,
+ expiry time.Duration,
+ userID,
+ resourceOwner string,
+ sentCommand func(ctx context.Context, userID string, resourceOwner string) (err error),
+ eventTypes ...eventstore.EventType,
+) (*handler.Statement, error) {
+ ctx := HandlerContext(event.Aggregate())
+ alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, expiry, nil, eventTypes...)
if err != nil {
return nil, err
}
if alreadyHandled {
- return crdb.NewNoOpStatement(e), nil
+ return crdb.NewNoOpStatement(event), nil
}
- code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
+ plainCode, err := crypto.DecryptString(code, u.queries.UserDataCrypto)
if err != nil {
return nil, err
}
- colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
+ colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, resourceOwner, false)
if err != nil {
return nil, err
}
- notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID, false)
+ notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, userID, false)
if err != nil {
return nil, err
}
@@ -386,19 +451,19 @@ func (u *userNotifier) reduceOTPSMSCodeAdded(event eventstore.Event) (*handler.S
u.queries.GetLogProvider,
colors,
u.assetsPrefix(ctx),
- e,
+ event,
u.metricSuccessfulDeliveriesSMS,
u.metricFailedDeliveriesSMS,
)
- err = notify.SendOTPSMSCode(authz.GetInstance(ctx).RequestedDomain(), origin, code, e.Expiry)
+ err = notify.SendOTPSMSCode(authz.GetInstance(ctx).RequestedDomain(), origin, plainCode, expiry)
if err != nil {
return nil, err
}
- err = u.commands.HumanOTPSMSCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner)
+ err = sentCommand(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
- return crdb.NewNoOpStatement(e), nil
+ return crdb.NewNoOpStatement(event), nil
}
func (u *userNotifier) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) {
@@ -406,34 +471,100 @@ func (u *userNotifier) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-JL3hw", "reduce.wrong.event.type %s", user.HumanOTPEmailCodeAddedType)
}
+ var authRequestID string
+ if e.AuthRequestInfo != nil {
+ authRequestID = e.AuthRequestInfo.ID
+ }
+ url := func(code, origin string, _ *query.NotifyUser) (string, error) {
+ return login.OTPLink(origin, authRequestID, code, domain.MFATypeOTPEmail), nil
+ }
+ return u.reduceOTPEmail(
+ e,
+ e.Code,
+ e.Expiry,
+ e.Aggregate().ID,
+ e.Aggregate().ResourceOwner,
+ url,
+ u.commands.HumanOTPEmailCodeSent,
+ user.HumanOTPEmailCodeAddedType,
+ user.HumanOTPEmailCodeSentType,
+ )
+}
+
+func (u *userNotifier) reduceSessionOTPEmailChallenged(event eventstore.Event) (*handler.Statement, error) {
+ e, ok := event.(*session.OTPEmailChallengedEvent)
+ if !ok {
+ return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-zbsgt", "reduce.wrong.event.type %s", session.OTPEmailChallengedType)
+ }
+ if e.ReturnCode {
+ return crdb.NewNoOpStatement(e), nil
+ }
ctx := HandlerContext(event.Aggregate())
- alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
- user.HumanOTPEmailCodeAddedType, user.HumanOTPEmailCodeSentType)
+ s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "")
+ if err != nil {
+ return nil, err
+ }
+ url := func(code, origin string, user *query.NotifyUser) (string, error) {
+ var buf strings.Builder
+ urlTmpl := origin + u.otpEmailTmpl
+ if e.URLTmpl != "" {
+ urlTmpl = e.URLTmpl
+ }
+ if err := domain.RenderOTPEmailURLTemplate(&buf, urlTmpl, code, user.ID, user.PreferredLoginName, user.DisplayName, user.PreferredLanguage); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+ }
+ return u.reduceOTPEmail(
+ e,
+ e.Code,
+ e.Expiry,
+ s.UserFactor.UserID,
+ s.UserFactor.ResourceOwner,
+ url,
+ u.commands.OTPEmailSent,
+ user.HumanOTPEmailCodeAddedType,
+ user.HumanOTPEmailCodeSentType,
+ )
+}
+
+func (u *userNotifier) reduceOTPEmail(
+ event eventstore.Event,
+ code *crypto.CryptoValue,
+ expiry time.Duration,
+ userID,
+ resourceOwner string,
+ urlTmpl func(code, origin string, user *query.NotifyUser) (string, error),
+ sentCommand func(ctx context.Context, userID string, resourceOwner string) (err error),
+ eventTypes ...eventstore.EventType,
+) (*handler.Statement, error) {
+ ctx := HandlerContext(event.Aggregate())
+ alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, expiry, nil, eventTypes...)
if err != nil {
return nil, err
}
if alreadyHandled {
- return crdb.NewNoOpStatement(e), nil
+ return crdb.NewNoOpStatement(event), nil
}
- code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
+ plainCode, err := crypto.DecryptString(code, u.queries.UserDataCrypto)
if err != nil {
return nil, err
}
- colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
+ colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, resourceOwner, false)
if err != nil {
return nil, err
}
- template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
+ template, err := u.queries.MailTemplateByOrg(ctx, resourceOwner, false)
if err != nil {
return nil, err
}
- notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID, false)
+ notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, userID, false)
if err != nil {
return nil, err
}
- translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifyEmailOTPMessageType)
+ translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, resourceOwner, domain.VerifyEmailOTPMessageType)
if err != nil {
return nil, err
}
@@ -442,9 +573,9 @@ func (u *userNotifier) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler
if err != nil {
return nil, err
}
- var authRequestID string
- if e.AuthRequestInfo != nil {
- authRequestID = e.AuthRequestInfo.ID
+ url, err := urlTmpl(plainCode, origin, notifyUser)
+ if err != nil {
+ return nil, err
}
notify := types.SendEmail(
ctx,
@@ -456,19 +587,19 @@ func (u *userNotifier) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler
u.queries.GetLogProvider,
colors,
u.assetsPrefix(ctx),
- e,
+ event,
u.metricSuccessfulDeliveriesEmail,
u.metricFailedDeliveriesEmail,
)
- err = notify.SendOTPEmailCode(notifyUser, authz.GetInstance(ctx).RequestedDomain(), origin, code, authRequestID, e.Expiry)
+ err = notify.SendOTPEmailCode(notifyUser, url, authz.GetInstance(ctx).RequestedDomain(), origin, plainCode, expiry)
if err != nil {
return nil, err
}
- err = u.commands.HumanOTPEmailCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner)
+ err = sentCommand(ctx, event.Aggregate().ID, event.Aggregate().ResourceOwner)
if err != nil {
return nil, err
}
- return crdb.NewNoOpStatement(e), nil
+ return crdb.NewNoOpStatement(event), nil
}
func (u *userNotifier) reduceDomainClaimed(event eventstore.Event) (*handler.Statement, error) {
diff --git a/internal/notification/projections.go b/internal/notification/projections.go
index 0a6f6f659c..380775254f 100644
--- a/internal/notification/projections.go
+++ b/internal/notification/projections.go
@@ -27,9 +27,7 @@ const (
func Start(
ctx context.Context,
- userHandlerCustomConfig projection.CustomConfig,
- quotaHandlerCustomConfig projection.CustomConfig,
- telemetryHandlerCustomConfig projection.CustomConfig,
+ userHandlerCustomConfig, quotaHandlerCustomConfig, telemetryHandlerCustomConfig projection.CustomConfig,
telemetryCfg handlers.TelemetryPusherConfig,
externalDomain string,
externalPort uint16,
@@ -38,10 +36,9 @@ func Start(
queries *query.Queries,
es *eventstore.Eventstore,
assetsPrefix func(context.Context) string,
+ otpEmailTmpl string,
fileSystemPath string,
- userEncryption,
- smtpEncryption,
- smsEncryption crypto.EncryptionAlgorithm,
+ userEncryption, smtpEncryption, smsEncryption crypto.EncryptionAlgorithm,
) {
statikFS, err := statik_fs.NewWithNamespace("notification")
logging.OnError(err).Panic("unable to start listener")
@@ -64,6 +61,7 @@ func Start(
commands,
q,
assetsPrefix,
+ otpEmailTmpl,
metricSuccessfulDeliveriesEmail,
metricFailedDeliveriesEmail,
metricSuccessfulDeliveriesSMS,
diff --git a/internal/notification/types/otp.go b/internal/notification/types/otp.go
index aea3a5c124..2079ecce01 100644
--- a/internal/notification/types/otp.go
+++ b/internal/notification/types/otp.go
@@ -3,7 +3,6 @@ package types
import (
"time"
- "github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
@@ -13,8 +12,7 @@ func (notify Notify) SendOTPSMSCode(requestedDomain, origin, code string, expiry
return notify("", args, domain.VerifySMSOTPMessageType, false)
}
-func (notify Notify) SendOTPEmailCode(user *query.NotifyUser, requestedDomain, origin, code, authRequestID string, expiry time.Duration) error {
- url := login.OTPLink(origin, authRequestID, code, domain.MFATypeOTPEmail)
+func (notify Notify) SendOTPEmailCode(user *query.NotifyUser, url, requestedDomain, origin, code string, expiry time.Duration) error {
args := otpArgs(code, origin, requestedDomain, expiry)
return notify(url, args, domain.VerifyEmailOTPMessageType, false)
}
diff --git a/internal/query/projection/session.go b/internal/query/projection/session.go
index 654e804270..7305d6a87c 100644
--- a/internal/query/projection/session.go
+++ b/internal/query/projection/session.go
@@ -14,7 +14,7 @@ import (
)
const (
- SessionsProjectionTable = "projections.sessions4"
+ SessionsProjectionTable = "projections.sessions5"
SessionColumnID = "id"
SessionColumnCreationDate = "creation_date"
@@ -31,6 +31,8 @@ const (
SessionColumnWebAuthNCheckedAt = "webauthn_checked_at"
SessionColumnWebAuthNUserVerified = "webauthn_user_verified"
SessionColumnTOTPCheckedAt = "totp_checked_at"
+ SessionColumnOTPSMSCheckedAt = "otp_sms_checked_at"
+ SessionColumnOTPEmailCheckedAt = "otp_email_checked_at"
SessionColumnMetadata = "metadata"
SessionColumnTokenID = "token_id"
)
@@ -60,6 +62,8 @@ func newSessionProjection(ctx context.Context, config crdb.StatementHandlerConfi
crdb.NewColumn(SessionColumnWebAuthNCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
crdb.NewColumn(SessionColumnWebAuthNUserVerified, crdb.ColumnTypeBool, crdb.Nullable()),
crdb.NewColumn(SessionColumnTOTPCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
+ crdb.NewColumn(SessionColumnOTPSMSCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
+ crdb.NewColumn(SessionColumnOTPEmailCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
crdb.NewColumn(SessionColumnMetadata, crdb.ColumnTypeJSONB, crdb.Nullable()),
crdb.NewColumn(SessionColumnTokenID, crdb.ColumnTypeText, crdb.Nullable()),
},
@@ -99,6 +103,14 @@ func (p *sessionProjection) reducers() []handler.AggregateReducer {
Event: session.TOTPCheckedType,
Reduce: p.reduceTOTPChecked,
},
+ {
+ Event: session.OTPSMSCheckedType,
+ Reduce: p.reduceOTPSMSChecked,
+ },
+ {
+ Event: session.OTPEmailCheckedType,
+ Reduce: p.reduceOTPEmailChecked,
+ },
{
Event: session.TokenSetType,
Reduce: p.reduceTokenSet,
@@ -255,6 +267,46 @@ func (p *sessionProjection) reduceTOTPChecked(event eventstore.Event) (*handler.
), nil
}
+func (p *sessionProjection) reduceOTPSMSChecked(event eventstore.Event) (*handler.Statement, error) {
+ e, err := assertEvent[*session.OTPSMSCheckedEvent](event)
+ if err != nil {
+ return nil, err
+ }
+
+ return crdb.NewUpdateStatement(
+ e,
+ []handler.Column{
+ handler.NewCol(SessionColumnChangeDate, e.CreationDate()),
+ handler.NewCol(SessionColumnSequence, e.Sequence()),
+ handler.NewCol(SessionColumnOTPSMSCheckedAt, e.CheckedAt),
+ },
+ []handler.Condition{
+ handler.NewCond(SessionColumnID, e.Aggregate().ID),
+ handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID),
+ },
+ ), nil
+}
+
+func (p *sessionProjection) reduceOTPEmailChecked(event eventstore.Event) (*handler.Statement, error) {
+ e, err := assertEvent[*session.OTPEmailCheckedEvent](event)
+ if err != nil {
+ return nil, err
+ }
+
+ return crdb.NewUpdateStatement(
+ e,
+ []handler.Column{
+ handler.NewCol(SessionColumnChangeDate, e.CreationDate()),
+ handler.NewCol(SessionColumnSequence, e.Sequence()),
+ handler.NewCol(SessionColumnOTPEmailCheckedAt, e.CheckedAt),
+ },
+ []handler.Condition{
+ handler.NewCond(SessionColumnID, e.Aggregate().ID),
+ handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID),
+ },
+ ), nil
+}
+
func (p *sessionProjection) reduceTokenSet(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.TokenSetEvent)
if !ok {
diff --git a/internal/query/projection/session_test.go b/internal/query/projection/session_test.go
index c22310d620..8ac52b7484 100644
--- a/internal/query/projection/session_test.go
+++ b/internal/query/projection/session_test.go
@@ -43,7 +43,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
- expectedStmt: "INSERT INTO projections.sessions4 (id, instance_id, creation_date, change_date, resource_owner, state, sequence, creator) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
+ expectedStmt: "INSERT INTO projections.sessions5 (id, instance_id, creation_date, change_date, resource_owner, state, sequence, creator) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
expectedArgs: []interface{}{
"agg-id",
"instance-id",
@@ -79,7 +79,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
- expectedStmt: "UPDATE projections.sessions4 SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)",
+ expectedStmt: "UPDATE projections.sessions5 SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
@@ -112,7 +112,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
- expectedStmt: "UPDATE projections.sessions4 SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
+ expectedStmt: "UPDATE projections.sessions5 SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
@@ -145,7 +145,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
- expectedStmt: "UPDATE projections.sessions4 SET (change_date, sequence, webauthn_checked_at, webauthn_user_verified) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)",
+ expectedStmt: "UPDATE projections.sessions5 SET (change_date, sequence, webauthn_checked_at, webauthn_user_verified) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
@@ -178,7 +178,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
- expectedStmt: "UPDATE projections.sessions4 SET (change_date, sequence, intent_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
+ expectedStmt: "UPDATE projections.sessions5 SET (change_date, sequence, intent_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
@@ -210,7 +210,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
- expectedStmt: "UPDATE projections.sessions4 SET (change_date, sequence, totp_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
+ expectedStmt: "UPDATE projections.sessions5 SET (change_date, sequence, totp_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
@@ -242,7 +242,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
- expectedStmt: "UPDATE projections.sessions4 SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
+ expectedStmt: "UPDATE projections.sessions5 SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
@@ -276,7 +276,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
- expectedStmt: "UPDATE projections.sessions4 SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
+ expectedStmt: "UPDATE projections.sessions5 SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
@@ -308,7 +308,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
- expectedStmt: "DELETE FROM projections.sessions4 WHERE (id = $1) AND (instance_id = $2)",
+ expectedStmt: "DELETE FROM projections.sessions5 WHERE (id = $1) AND (instance_id = $2)",
expectedArgs: []interface{}{
"agg-id",
"instance-id",
@@ -335,7 +335,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
- expectedStmt: "DELETE FROM projections.sessions4 WHERE (instance_id = $1)",
+ expectedStmt: "DELETE FROM projections.sessions5 WHERE (instance_id = $1)",
expectedArgs: []interface{}{
"agg-id",
},
@@ -366,7 +366,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
- expectedStmt: "UPDATE projections.sessions4 SET password_checked_at = $1 WHERE (user_id = $2) AND (password_checked_at < $3)",
+ expectedStmt: "UPDATE projections.sessions5 SET password_checked_at = $1 WHERE (user_id = $2) AND (password_checked_at < $3)",
expectedArgs: []interface{}{
nil,
"agg-id",
diff --git a/internal/query/session.go b/internal/query/session.go
index 2a1672a3fa..c098d3d110 100644
--- a/internal/query/session.go
+++ b/internal/query/session.go
@@ -35,6 +35,8 @@ type Session struct {
IntentFactor SessionIntentFactor
WebAuthNFactor SessionWebAuthNFactor
TOTPFactor SessionTOTPFactor
+ OTPSMSFactor SessionOTPFactor
+ OTPEmailFactor SessionOTPFactor
Metadata map[string][]byte
}
@@ -63,6 +65,10 @@ type SessionTOTPFactor struct {
TOTPCheckedAt time.Time
}
+type SessionOTPFactor struct {
+ OTPCheckedAt time.Time
+}
+
type SessionsSearchQueries struct {
SearchRequest
Queries []SearchQuery
@@ -141,6 +147,14 @@ var (
name: projection.SessionColumnTOTPCheckedAt,
table: sessionsTable,
}
+ SessionColumnOTPSMSCheckedAt = Column{
+ name: projection.SessionColumnOTPSMSCheckedAt,
+ table: sessionsTable,
+ }
+ SessionColumnOTPEmailCheckedAt = Column{
+ name: projection.SessionColumnOTPEmailCheckedAt,
+ table: sessionsTable,
+ }
SessionColumnMetadata = Column{
name: projection.SessionColumnMetadata,
table: sessionsTable,
@@ -243,6 +257,8 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
SessionColumnWebAuthNCheckedAt.identifier(),
SessionColumnWebAuthNUserVerified.identifier(),
SessionColumnTOTPCheckedAt.identifier(),
+ SessionColumnOTPSMSCheckedAt.identifier(),
+ SessionColumnOTPEmailCheckedAt.identifier(),
SessionColumnMetadata.identifier(),
SessionColumnToken.identifier(),
).From(sessionsTable.identifier()).
@@ -263,6 +279,8 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
webAuthNCheckedAt sql.NullTime
webAuthNUserPresent sql.NullBool
totpCheckedAt sql.NullTime
+ otpSMSCheckedAt sql.NullTime
+ otpEmailCheckedAt sql.NullTime
metadata database.Map[[]byte]
token sql.NullString
)
@@ -285,6 +303,8 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
&webAuthNCheckedAt,
&webAuthNUserPresent,
&totpCheckedAt,
+ &otpSMSCheckedAt,
+ &otpEmailCheckedAt,
&metadata,
&token,
)
@@ -306,6 +326,8 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
session.WebAuthNFactor.WebAuthNCheckedAt = webAuthNCheckedAt.Time
session.WebAuthNFactor.UserVerified = webAuthNUserPresent.Bool
session.TOTPFactor.TOTPCheckedAt = totpCheckedAt.Time
+ session.OTPSMSFactor.OTPCheckedAt = otpSMSCheckedAt.Time
+ session.OTPEmailFactor.OTPCheckedAt = otpEmailCheckedAt.Time
session.Metadata = metadata
return session, token.String, nil
@@ -331,6 +353,8 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
SessionColumnWebAuthNCheckedAt.identifier(),
SessionColumnWebAuthNUserVerified.identifier(),
SessionColumnTOTPCheckedAt.identifier(),
+ SessionColumnOTPSMSCheckedAt.identifier(),
+ SessionColumnOTPEmailCheckedAt.identifier(),
SessionColumnMetadata.identifier(),
countColumn.identifier(),
).From(sessionsTable.identifier()).
@@ -354,6 +378,8 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
webAuthNCheckedAt sql.NullTime
webAuthNUserPresent sql.NullBool
totpCheckedAt sql.NullTime
+ otpSMSCheckedAt sql.NullTime
+ otpEmailCheckedAt sql.NullTime
metadata database.Map[[]byte]
)
@@ -375,6 +401,8 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
&webAuthNCheckedAt,
&webAuthNUserPresent,
&totpCheckedAt,
+ &otpSMSCheckedAt,
+ &otpEmailCheckedAt,
&metadata,
&sessions.Count,
)
@@ -392,6 +420,8 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
session.WebAuthNFactor.WebAuthNCheckedAt = webAuthNCheckedAt.Time
session.WebAuthNFactor.UserVerified = webAuthNUserPresent.Bool
session.TOTPFactor.TOTPCheckedAt = totpCheckedAt.Time
+ session.OTPSMSFactor.OTPCheckedAt = otpSMSCheckedAt.Time
+ session.OTPEmailFactor.OTPCheckedAt = otpEmailCheckedAt.Time
session.Metadata = metadata
sessions.Sessions = append(sessions.Sessions, session)
diff --git a/internal/query/sessions_test.go b/internal/query/sessions_test.go
index c66868a9d6..fa5209bdd3 100644
--- a/internal/query/sessions_test.go
+++ b/internal/query/sessions_test.go
@@ -17,53 +17,57 @@ import (
)
var (
- expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions4.id,` +
- ` projections.sessions4.creation_date,` +
- ` projections.sessions4.change_date,` +
- ` projections.sessions4.sequence,` +
- ` projections.sessions4.state,` +
- ` projections.sessions4.resource_owner,` +
- ` projections.sessions4.creator,` +
- ` projections.sessions4.user_id,` +
- ` projections.sessions4.user_checked_at,` +
+ expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions5.id,` +
+ ` projections.sessions5.creation_date,` +
+ ` projections.sessions5.change_date,` +
+ ` projections.sessions5.sequence,` +
+ ` projections.sessions5.state,` +
+ ` projections.sessions5.resource_owner,` +
+ ` projections.sessions5.creator,` +
+ ` projections.sessions5.user_id,` +
+ ` projections.sessions5.user_checked_at,` +
` projections.login_names2.login_name,` +
` projections.users8_humans.display_name,` +
` projections.users8.resource_owner,` +
- ` projections.sessions4.password_checked_at,` +
- ` projections.sessions4.intent_checked_at,` +
- ` projections.sessions4.webauthn_checked_at,` +
- ` projections.sessions4.webauthn_user_verified,` +
- ` projections.sessions4.totp_checked_at,` +
- ` projections.sessions4.metadata,` +
- ` projections.sessions4.token_id` +
- ` FROM projections.sessions4` +
- ` LEFT JOIN projections.login_names2 ON projections.sessions4.user_id = projections.login_names2.user_id AND projections.sessions4.instance_id = projections.login_names2.instance_id` +
- ` LEFT JOIN projections.users8_humans ON projections.sessions4.user_id = projections.users8_humans.user_id AND projections.sessions4.instance_id = projections.users8_humans.instance_id` +
- ` LEFT JOIN projections.users8 ON projections.sessions4.user_id = projections.users8.id AND projections.sessions4.instance_id = projections.users8.instance_id` +
+ ` projections.sessions5.password_checked_at,` +
+ ` projections.sessions5.intent_checked_at,` +
+ ` projections.sessions5.webauthn_checked_at,` +
+ ` projections.sessions5.webauthn_user_verified,` +
+ ` projections.sessions5.totp_checked_at,` +
+ ` projections.sessions5.otp_sms_checked_at,` +
+ ` projections.sessions5.otp_email_checked_at,` +
+ ` projections.sessions5.metadata,` +
+ ` projections.sessions5.token_id` +
+ ` FROM projections.sessions5` +
+ ` LEFT JOIN projections.login_names2 ON projections.sessions5.user_id = projections.login_names2.user_id AND projections.sessions5.instance_id = projections.login_names2.instance_id` +
+ ` LEFT JOIN projections.users8_humans ON projections.sessions5.user_id = projections.users8_humans.user_id AND projections.sessions5.instance_id = projections.users8_humans.instance_id` +
+ ` LEFT JOIN projections.users8 ON projections.sessions5.user_id = projections.users8.id AND projections.sessions5.instance_id = projections.users8.instance_id` +
` AS OF SYSTEM TIME '-1 ms'`)
- expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions4.id,` +
- ` projections.sessions4.creation_date,` +
- ` projections.sessions4.change_date,` +
- ` projections.sessions4.sequence,` +
- ` projections.sessions4.state,` +
- ` projections.sessions4.resource_owner,` +
- ` projections.sessions4.creator,` +
- ` projections.sessions4.user_id,` +
- ` projections.sessions4.user_checked_at,` +
+ expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions5.id,` +
+ ` projections.sessions5.creation_date,` +
+ ` projections.sessions5.change_date,` +
+ ` projections.sessions5.sequence,` +
+ ` projections.sessions5.state,` +
+ ` projections.sessions5.resource_owner,` +
+ ` projections.sessions5.creator,` +
+ ` projections.sessions5.user_id,` +
+ ` projections.sessions5.user_checked_at,` +
` projections.login_names2.login_name,` +
` projections.users8_humans.display_name,` +
` projections.users8.resource_owner,` +
- ` projections.sessions4.password_checked_at,` +
- ` projections.sessions4.intent_checked_at,` +
- ` projections.sessions4.webauthn_checked_at,` +
- ` projections.sessions4.webauthn_user_verified,` +
- ` projections.sessions4.totp_checked_at,` +
- ` projections.sessions4.metadata,` +
+ ` projections.sessions5.password_checked_at,` +
+ ` projections.sessions5.intent_checked_at,` +
+ ` projections.sessions5.webauthn_checked_at,` +
+ ` projections.sessions5.webauthn_user_verified,` +
+ ` projections.sessions5.totp_checked_at,` +
+ ` projections.sessions5.otp_sms_checked_at,` +
+ ` projections.sessions5.otp_email_checked_at,` +
+ ` projections.sessions5.metadata,` +
` COUNT(*) OVER ()` +
- ` FROM projections.sessions4` +
- ` LEFT JOIN projections.login_names2 ON projections.sessions4.user_id = projections.login_names2.user_id AND projections.sessions4.instance_id = projections.login_names2.instance_id` +
- ` LEFT JOIN projections.users8_humans ON projections.sessions4.user_id = projections.users8_humans.user_id AND projections.sessions4.instance_id = projections.users8_humans.instance_id` +
- ` LEFT JOIN projections.users8 ON projections.sessions4.user_id = projections.users8.id AND projections.sessions4.instance_id = projections.users8.instance_id` +
+ ` FROM projections.sessions5` +
+ ` LEFT JOIN projections.login_names2 ON projections.sessions5.user_id = projections.login_names2.user_id AND projections.sessions5.instance_id = projections.login_names2.instance_id` +
+ ` LEFT JOIN projections.users8_humans ON projections.sessions5.user_id = projections.users8_humans.user_id AND projections.sessions5.instance_id = projections.users8_humans.instance_id` +
+ ` LEFT JOIN projections.users8 ON projections.sessions5.user_id = projections.users8.id AND projections.sessions5.instance_id = projections.users8.instance_id` +
` AS OF SYSTEM TIME '-1 ms'`)
sessionCols = []string{
@@ -84,6 +88,8 @@ var (
"webauthn_checked_at",
"webauthn_user_verified",
"totp_checked_at",
+ "otp_sms_checked_at",
+ "otp_email_checked_at",
"metadata",
"token",
}
@@ -106,6 +112,8 @@ var (
"webauthn_checked_at",
"webauthn_user_verified",
"totp_checked_at",
+ "otp_sms_checked_at",
+ "otp_email_checked_at",
"metadata",
"count",
}
@@ -160,6 +168,8 @@ func Test_SessionsPrepare(t *testing.T) {
testNow,
true,
testNow,
+ testNow,
+ testNow,
[]byte(`{"key": "dmFsdWU="}`),
},
},
@@ -198,6 +208,12 @@ func Test_SessionsPrepare(t *testing.T) {
TOTPFactor: SessionTOTPFactor{
TOTPCheckedAt: testNow,
},
+ OTPSMSFactor: SessionOTPFactor{
+ OTPCheckedAt: testNow,
+ },
+ OTPEmailFactor: SessionOTPFactor{
+ OTPCheckedAt: testNow,
+ },
Metadata: map[string][]byte{
"key": []byte("value"),
},
@@ -231,6 +247,8 @@ func Test_SessionsPrepare(t *testing.T) {
testNow,
true,
testNow,
+ testNow,
+ testNow,
[]byte(`{"key": "dmFsdWU="}`),
},
{
@@ -251,6 +269,8 @@ func Test_SessionsPrepare(t *testing.T) {
testNow,
false,
testNow,
+ testNow,
+ testNow,
[]byte(`{"key": "dmFsdWU="}`),
},
},
@@ -289,6 +309,12 @@ func Test_SessionsPrepare(t *testing.T) {
TOTPFactor: SessionTOTPFactor{
TOTPCheckedAt: testNow,
},
+ OTPSMSFactor: SessionOTPFactor{
+ OTPCheckedAt: testNow,
+ },
+ OTPEmailFactor: SessionOTPFactor{
+ OTPCheckedAt: testNow,
+ },
Metadata: map[string][]byte{
"key": []byte("value"),
},
@@ -321,6 +347,12 @@ func Test_SessionsPrepare(t *testing.T) {
TOTPFactor: SessionTOTPFactor{
TOTPCheckedAt: testNow,
},
+ OTPSMSFactor: SessionOTPFactor{
+ OTPCheckedAt: testNow,
+ },
+ OTPEmailFactor: SessionOTPFactor{
+ OTPCheckedAt: testNow,
+ },
Metadata: map[string][]byte{
"key": []byte("value"),
},
@@ -407,6 +439,8 @@ func Test_SessionPrepare(t *testing.T) {
testNow,
true,
testNow,
+ testNow,
+ testNow,
[]byte(`{"key": "dmFsdWU="}`),
"tokenID",
},
@@ -440,6 +474,12 @@ func Test_SessionPrepare(t *testing.T) {
TOTPFactor: SessionTOTPFactor{
TOTPCheckedAt: testNow,
},
+ OTPSMSFactor: SessionOTPFactor{
+ OTPCheckedAt: testNow,
+ },
+ OTPEmailFactor: SessionOTPFactor{
+ OTPCheckedAt: testNow,
+ },
Metadata: map[string][]byte{
"key": []byte("value"),
},
diff --git a/internal/repository/session/eventstore.go b/internal/repository/session/eventstore.go
index efa52b6582..2923e5239e 100644
--- a/internal/repository/session/eventstore.go
+++ b/internal/repository/session/eventstore.go
@@ -10,6 +10,12 @@ func RegisterEventMappers(es *eventstore.Eventstore) {
RegisterFilterEventMapper(AggregateType, WebAuthNChallengedType, eventstore.GenericEventMapper[WebAuthNChallengedEvent]).
RegisterFilterEventMapper(AggregateType, WebAuthNCheckedType, eventstore.GenericEventMapper[WebAuthNCheckedEvent]).
RegisterFilterEventMapper(AggregateType, TOTPCheckedType, eventstore.GenericEventMapper[TOTPCheckedEvent]).
+ RegisterFilterEventMapper(AggregateType, OTPSMSChallengedType, eventstore.GenericEventMapper[OTPSMSChallengedEvent]).
+ RegisterFilterEventMapper(AggregateType, OTPSMSSentType, eventstore.GenericEventMapper[OTPSMSSentEvent]).
+ RegisterFilterEventMapper(AggregateType, OTPSMSCheckedType, eventstore.GenericEventMapper[OTPSMSCheckedEvent]).
+ RegisterFilterEventMapper(AggregateType, OTPEmailChallengedType, eventstore.GenericEventMapper[OTPEmailChallengedEvent]).
+ RegisterFilterEventMapper(AggregateType, OTPEmailSentType, eventstore.GenericEventMapper[OTPEmailSentEvent]).
+ RegisterFilterEventMapper(AggregateType, OTPEmailCheckedType, eventstore.GenericEventMapper[OTPEmailCheckedEvent]).
RegisterFilterEventMapper(AggregateType, TokenSetType, TokenSetEventMapper).
RegisterFilterEventMapper(AggregateType, MetadataSetType, MetadataSetEventMapper).
RegisterFilterEventMapper(AggregateType, TerminateType, TerminateEventMapper)
diff --git a/internal/repository/session/session.go b/internal/repository/session/session.go
index 556cd033c7..76f4984d1d 100644
--- a/internal/repository/session/session.go
+++ b/internal/repository/session/session.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"time"
+ "github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
@@ -20,6 +21,12 @@ const (
WebAuthNChallengedType = sessionEventPrefix + "webAuthN.challenged"
WebAuthNCheckedType = sessionEventPrefix + "webAuthN.checked"
TOTPCheckedType = sessionEventPrefix + "totp.checked"
+ OTPSMSChallengedType = sessionEventPrefix + "otp.sms.challenged"
+ OTPSMSSentType = sessionEventPrefix + "otp.sms.sent"
+ OTPSMSCheckedType = sessionEventPrefix + "otp.sms.checked"
+ OTPEmailChallengedType = sessionEventPrefix + "otp.email.challenged"
+ OTPEmailSentType = sessionEventPrefix + "otp.email.sent"
+ OTPEmailCheckedType = sessionEventPrefix + "otp.email.checked"
TokenSetType = sessionEventPrefix + "token.set"
MetadataSetType = sessionEventPrefix + "metadata.set"
TerminateType = sessionEventPrefix + "terminated"
@@ -298,6 +305,211 @@ func NewTOTPCheckedEvent(
}
}
+type OTPSMSChallengedEvent struct {
+ eventstore.BaseEvent `json:"-"`
+
+ Code *crypto.CryptoValue `json:"code"`
+ Expiry time.Duration `json:"expiry"`
+ CodeReturned bool `json:"codeReturned,omitempty"`
+}
+
+func (e *OTPSMSChallengedEvent) Data() interface{} {
+ return e
+}
+
+func (e *OTPSMSChallengedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func (e *OTPSMSChallengedEvent) SetBaseEvent(base *eventstore.BaseEvent) {
+ e.BaseEvent = *base
+}
+
+func NewOTPSMSChallengedEvent(
+ ctx context.Context,
+ aggregate *eventstore.Aggregate,
+ code *crypto.CryptoValue,
+ expiry time.Duration,
+ codeReturned bool,
+) *OTPSMSChallengedEvent {
+ return &OTPSMSChallengedEvent{
+ BaseEvent: *eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ OTPSMSChallengedType,
+ ),
+ Code: code,
+ Expiry: expiry,
+ CodeReturned: codeReturned,
+ }
+}
+
+type OTPSMSSentEvent struct {
+ eventstore.BaseEvent `json:"-"`
+}
+
+func (e *OTPSMSSentEvent) Data() interface{} {
+ return e
+}
+
+func (e *OTPSMSSentEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func (e *OTPSMSSentEvent) SetBaseEvent(base *eventstore.BaseEvent) {
+ e.BaseEvent = *base
+}
+
+func NewOTPSMSSentEvent(
+ ctx context.Context,
+ aggregate *eventstore.Aggregate,
+) *OTPSMSSentEvent {
+ return &OTPSMSSentEvent{
+ BaseEvent: *eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ OTPSMSSentType,
+ ),
+ }
+}
+
+type OTPSMSCheckedEvent struct {
+ eventstore.BaseEvent `json:"-"`
+
+ CheckedAt time.Time `json:"checkedAt"`
+}
+
+func (e *OTPSMSCheckedEvent) Data() interface{} {
+ return e
+}
+
+func (e *OTPSMSCheckedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func (e *OTPSMSCheckedEvent) SetBaseEvent(base *eventstore.BaseEvent) {
+ e.BaseEvent = *base
+}
+
+func NewOTPSMSCheckedEvent(
+ ctx context.Context,
+ aggregate *eventstore.Aggregate,
+ checkedAt time.Time,
+) *OTPSMSCheckedEvent {
+ return &OTPSMSCheckedEvent{
+ BaseEvent: *eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ OTPSMSCheckedType,
+ ),
+ CheckedAt: checkedAt,
+ }
+}
+
+type OTPEmailChallengedEvent struct {
+ eventstore.BaseEvent `json:"-"`
+
+ Code *crypto.CryptoValue `json:"code"`
+ Expiry time.Duration `json:"expiry"`
+ ReturnCode bool `json:"returnCode,omitempty"`
+ URLTmpl string `json:"urlTmpl,omitempty"`
+}
+
+func (e *OTPEmailChallengedEvent) Data() interface{} {
+ return e
+}
+
+func (e *OTPEmailChallengedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func (e *OTPEmailChallengedEvent) SetBaseEvent(base *eventstore.BaseEvent) {
+ e.BaseEvent = *base
+}
+
+func NewOTPEmailChallengedEvent(
+ ctx context.Context,
+ aggregate *eventstore.Aggregate,
+ code *crypto.CryptoValue,
+ expiry time.Duration,
+ returnCode bool,
+ urlTmpl string,
+) *OTPEmailChallengedEvent {
+ return &OTPEmailChallengedEvent{
+ BaseEvent: *eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ OTPEmailChallengedType,
+ ),
+ Code: code,
+ Expiry: expiry,
+ ReturnCode: returnCode,
+ URLTmpl: urlTmpl,
+ }
+}
+
+type OTPEmailSentEvent struct {
+ eventstore.BaseEvent `json:"-"`
+}
+
+func (e *OTPEmailSentEvent) Data() interface{} {
+ return e
+}
+
+func (e *OTPEmailSentEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func (e *OTPEmailSentEvent) SetBaseEvent(base *eventstore.BaseEvent) {
+ e.BaseEvent = *base
+}
+
+func NewOTPEmailSentEvent(
+ ctx context.Context,
+ aggregate *eventstore.Aggregate,
+) *OTPEmailSentEvent {
+ return &OTPEmailSentEvent{
+ BaseEvent: *eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ OTPEmailSentType,
+ ),
+ }
+}
+
+type OTPEmailCheckedEvent struct {
+ eventstore.BaseEvent `json:"-"`
+
+ CheckedAt time.Time `json:"checkedAt"`
+}
+
+func (e *OTPEmailCheckedEvent) Data() interface{} {
+ return e
+}
+
+func (e *OTPEmailCheckedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func (e *OTPEmailCheckedEvent) SetBaseEvent(base *eventstore.BaseEvent) {
+ e.BaseEvent = *base
+}
+
+func NewOTPEmailCheckedEvent(
+ ctx context.Context,
+ aggregate *eventstore.Aggregate,
+ checkedAt time.Time,
+) *OTPEmailCheckedEvent {
+ return &OTPEmailCheckedEvent{
+ BaseEvent: *eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ OTPEmailCheckedType,
+ ),
+ CheckedAt: checkedAt,
+ }
+}
+
type TokenSetEvent struct {
eventstore.BaseEvent `json:"-"`
diff --git a/proto/zitadel/session/v2alpha/challenge.proto b/proto/zitadel/session/v2alpha/challenge.proto
index ed1ef6e647..b8c6b0c089 100644
--- a/proto/zitadel/session/v2alpha/challenge.proto
+++ b/proto/zitadel/session/v2alpha/challenge.proto
@@ -37,8 +37,33 @@ message RequestChallenges {
}
];
}
+ message OTPSMS {
+ bool return_code = 1;
+ }
+ message OTPEmail {
+ message SendCode {
+ optional string url_template = 1 [
+ (validate.rules).string = {min_len: 1, max_len: 200},
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ min_length: 1;
+ max_length: 200;
+ example: "\"https://example.com/otp/verify?userID={{.UserID}}&code={{.Code}}\"";
+ description: "\"Optionally set a url_template, which will be used in the mail sent by ZITADEL to guide the user to your verification page. If no template is set, the default ZITADEL url will be used.\""
+ }
+ ];
+ }
+ message ReturnCode {}
+
+ // if no delivery_type is specified, an email is sent with the default url
+ oneof delivery_type {
+ SendCode send_code = 2;
+ ReturnCode return_code = 3;
+ }
+ }
optional WebAuthN web_auth_n = 1;
+ optional OTPSMS otp_sms = 2;
+ optional OTPEmail otp_email = 3;
}
message Challenges {
@@ -52,4 +77,6 @@ message Challenges {
}
optional WebAuthN web_auth_n = 1;
+ optional string otp_sms = 2;
+ optional string otp_email = 3;
}
diff --git a/proto/zitadel/session/v2alpha/session.proto b/proto/zitadel/session/v2alpha/session.proto
index 44f337c0d6..5c0bcb3115 100644
--- a/proto/zitadel/session/v2alpha/session.proto
+++ b/proto/zitadel/session/v2alpha/session.proto
@@ -47,6 +47,8 @@ message Factors {
WebAuthNFactor web_auth_n = 3;
IntentFactor intent = 4;
TOTPFactor totp = 5;
+ OTPFactor otp_sms = 6;
+ OTPFactor otp_email = 7;
}
message UserFactor {
@@ -110,6 +112,14 @@ message TOTPFactor {
];
}
+message OTPFactor {
+ google.protobuf.Timestamp verified_at = 1 [
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ description: "\"time when the One-Time Password was last checked\"";
+ }
+ ];
+}
+
message SearchQuery {
oneof query {
option (validate.required) = true;
diff --git a/proto/zitadel/session/v2alpha/session_service.proto b/proto/zitadel/session/v2alpha/session_service.proto
index 9a4d017a3b..533b07e999 100644
--- a/proto/zitadel/session/v2alpha/session_service.proto
+++ b/proto/zitadel/session/v2alpha/session_service.proto
@@ -380,6 +380,16 @@ message Checks {
description: "\"Checks the Time-based One-Time Password and updates the session on success. Requires that the user is already checked, either in the previous or the same request.\"";
}
];
+ optional CheckOTP otp_sms = 6 [
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ description: "\"Checks the One-Time Password sent over SMS and updates the session on success. Requires that the user is already checked, either in the previous or the same request.\"";
+ }
+ ];
+ optional CheckOTP otp_email = 7 [
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ description: "\"Checks the One-Time Password sent over Email and updates the session on success. Requires that the user is already checked, either in the previous or the same request.\"";
+ }
+ ];
}
message CheckUser {
@@ -456,4 +466,14 @@ message CheckTOTP {
example: "\"323764\"";
}
];
+}
+
+message CheckOTP {
+ string otp = 1 [
+ (validate.rules).string = {min_len: 1},
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ min_length: 1;
+ example: "\"3237642\"";
+ }
+ ];
}
\ No newline at end of file
From 94d13fd3e13307cc78bd760f1c446650eb73d3fb Mon Sep 17 00:00:00 2001
From: Livio Spring
Date: Thu, 24 Aug 2023 12:31:12 +0200
Subject: [PATCH 22/35] fix(api): handle id_token_mapping in generic oidc
provider correctly (#6428)
---
internal/api/grpc/admin/idp_converter.go | 26 ++++++++++---------
internal/api/grpc/management/idp_converter.go | 13 +++++-----
2 files changed, 21 insertions(+), 18 deletions(-)
diff --git a/internal/api/grpc/admin/idp_converter.go b/internal/api/grpc/admin/idp_converter.go
index 7aeb7ec332..c9dd3ae085 100644
--- a/internal/api/grpc/admin/idp_converter.go
+++ b/internal/api/grpc/admin/idp_converter.go
@@ -231,23 +231,25 @@ func updateGenericOAuthProviderToCommand(req *admin_pb.UpdateGenericOAuthProvide
func addGenericOIDCProviderToCommand(req *admin_pb.AddGenericOIDCProviderRequest) command.GenericOIDCProvider {
return command.GenericOIDCProvider{
- Name: req.Name,
- Issuer: req.Issuer,
- ClientID: req.ClientId,
- ClientSecret: req.ClientSecret,
- Scopes: req.Scopes,
- IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions),
+ Name: req.Name,
+ Issuer: req.Issuer,
+ ClientID: req.ClientId,
+ ClientSecret: req.ClientSecret,
+ Scopes: req.Scopes,
+ IsIDTokenMapping: req.IsIdTokenMapping,
+ IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions),
}
}
func updateGenericOIDCProviderToCommand(req *admin_pb.UpdateGenericOIDCProviderRequest) command.GenericOIDCProvider {
return command.GenericOIDCProvider{
- Name: req.Name,
- Issuer: req.Issuer,
- ClientID: req.ClientId,
- ClientSecret: req.ClientSecret,
- Scopes: req.Scopes,
- IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions),
+ Name: req.Name,
+ Issuer: req.Issuer,
+ ClientID: req.ClientId,
+ ClientSecret: req.ClientSecret,
+ Scopes: req.Scopes,
+ IsIDTokenMapping: req.IsIdTokenMapping,
+ IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions),
}
}
diff --git a/internal/api/grpc/management/idp_converter.go b/internal/api/grpc/management/idp_converter.go
index efce720a89..0d70aca0dc 100644
--- a/internal/api/grpc/management/idp_converter.go
+++ b/internal/api/grpc/management/idp_converter.go
@@ -260,12 +260,13 @@ func addGenericOIDCProviderToCommand(req *mgmt_pb.AddGenericOIDCProviderRequest)
func updateGenericOIDCProviderToCommand(req *mgmt_pb.UpdateGenericOIDCProviderRequest) command.GenericOIDCProvider {
return command.GenericOIDCProvider{
- Name: req.Name,
- Issuer: req.Issuer,
- ClientID: req.ClientId,
- ClientSecret: req.ClientSecret,
- Scopes: req.Scopes,
- IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions),
+ Name: req.Name,
+ Issuer: req.Issuer,
+ ClientID: req.ClientId,
+ ClientSecret: req.ClientSecret,
+ Scopes: req.Scopes,
+ IsIDTokenMapping: req.IsIdTokenMapping,
+ IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions),
}
}
From 54508ebd8220fdde263eab0c682973b94967c419 Mon Sep 17 00:00:00 2001
From: Elio Bischof
Date: Fri, 25 Aug 2023 15:17:12 +0200
Subject: [PATCH 23/35] fix: change force local mfa on org (#6432)
* fix: change force local mfa on org
* fix test
---------
Co-authored-by: Livio Spring
---
internal/command/org_policy_login.go | 1 +
internal/command/org_policy_login_model.go | 4 ++++
internal/command/org_policy_login_test.go | 4 +++-
3 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/internal/command/org_policy_login.go b/internal/command/org_policy_login.go
index ddfb9169d2..2c7ccbf93f 100644
--- a/internal/command/org_policy_login.go
+++ b/internal/command/org_policy_login.go
@@ -473,6 +473,7 @@ func prepareChangeLoginPolicy(a *org.Aggregate, policy *ChangeLoginPolicy) prepa
policy.AllowRegister,
policy.AllowExternalIDP,
policy.ForceMFA,
+ policy.ForceMFALocalOnly,
policy.HidePasswordReset,
policy.IgnoreUnknownUsernames,
policy.AllowDomainDiscovery,
diff --git a/internal/command/org_policy_login_model.go b/internal/command/org_policy_login_model.go
index 1f546ea99c..6a7c24a9b9 100644
--- a/internal/command/org_policy_login_model.go
+++ b/internal/command/org_policy_login_model.go
@@ -67,6 +67,7 @@ func (wm *OrgLoginPolicyWriteModel) NewChangedEvent(
allowRegister,
allowExternalIDP,
forceMFA,
+ forceMFALocalOnly,
hidePasswordReset,
ignoreUnknownUsernames,
allowDomainDiscovery,
@@ -94,6 +95,9 @@ func (wm *OrgLoginPolicyWriteModel) NewChangedEvent(
if wm.ForceMFA != forceMFA {
changes = append(changes, policy.ChangeForceMFA(forceMFA))
}
+ if wm.ForceMFALocalOnly != forceMFALocalOnly {
+ changes = append(changes, policy.ChangeForceMFALocalOnly(forceMFALocalOnly))
+ }
if wm.HidePasswordReset != hidePasswordReset {
changes = append(changes, policy.ChangeHidePasswordReset(hidePasswordReset))
}
diff --git a/internal/command/org_policy_login_test.go b/internal/command/org_policy_login_test.go
index ebc96ac419..54d48062df 100644
--- a/internal/command/org_policy_login_test.go
+++ b/internal/command/org_policy_login_test.go
@@ -574,6 +574,7 @@ func TestCommandSide_ChangeLoginPolicy(t *testing.T) {
false,
false,
false,
+ false,
domain.PasswordlessTypeNotAllowed,
"",
&duration10,
@@ -2196,7 +2197,7 @@ func TestCommandSide_RemoveMultiFactorLoginPolicy(t *testing.T) {
}
func newLoginPolicyChangedEvent(ctx context.Context, orgID string,
- usernamePassword, register, externalIDP, mfa, passwordReset, ignoreUnknownUsernames, allowDomainDiscovery, disableLoginWithEmail, disableLoginWithPhone bool,
+ usernamePassword, register, externalIDP, mfa, mfaLocalOnly, passwordReset, ignoreUnknownUsernames, allowDomainDiscovery, disableLoginWithEmail, disableLoginWithPhone bool,
passwordlessType domain.PasswordlessType,
redirectURI string,
passwordLifetime, externalLoginLifetime, mfaInitSkipLifetime, secondFactorLifetime, multiFactorLifetime *time.Duration) *org.LoginPolicyChangedEvent {
@@ -2205,6 +2206,7 @@ func newLoginPolicyChangedEvent(ctx context.Context, orgID string,
policy.ChangeAllowRegister(register),
policy.ChangeAllowExternalIDP(externalIDP),
policy.ChangeForceMFA(mfa),
+ policy.ChangeForceMFALocalOnly(mfaLocalOnly),
policy.ChangeHidePasswordReset(passwordReset),
policy.ChangeIgnoreUnknownUsernames(ignoreUnknownUsernames),
policy.ChangeAllowDomainDiscovery(allowDomainDiscovery),
From 9b43e28c236cfd3dae6f52bcfa11f18fe4620707 Mon Sep 17 00:00:00 2001
From: JesseBot
Date: Fri, 25 Aug 2023 17:55:45 +0200
Subject: [PATCH 24/35] docs: Update kubernetes.mdx - update cockroachdb
`conf.single-node` helm parameter (#6382)
Update kubernetes.mdx - update cockroachdb conf.single-node parameter
Co-authored-by: Elio Bischof
---
docs/docs/self-hosting/deploy/kubernetes.mdx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/docs/self-hosting/deploy/kubernetes.mdx b/docs/docs/self-hosting/deploy/kubernetes.mdx
index b3e34b50e1..e3f4f0981c 100644
--- a/docs/docs/self-hosting/deploy/kubernetes.mdx
+++ b/docs/docs/self-hosting/deploy/kubernetes.mdx
@@ -31,7 +31,7 @@ you can setup ZITADEL and either
# Install CockroachDB
helm install crdb cockroachdb/cockroachdb \
--set fullnameOverride=crdb \
- --set single-node=true \
+ --set conf.single-node=true \
--set statefulset.replicas=1
# Install ZITADEL
@@ -65,7 +65,7 @@ With this setup you only get a key for a service account. Logging in at ZITADEL
# Install CockroachDB
helm install crdb cockroachdb/cockroachdb \
--set fullnameOverride=crdb \
- --set single-node=true \
+ --set conf.single-node=true \
--set statefulset.replicas=1
# Install ZITADEL
From fd00ac533a460fdab7cd7c38aafc5de56ee2f65d Mon Sep 17 00:00:00 2001
From: Miguel Cabrerizo <30386061+doncicuto@users.noreply.github.com>
Date: Tue, 29 Aug 2023 09:08:24 +0200
Subject: [PATCH 25/35] feat: add reply-to header in email notification (#6393)
* feat: add reply-to header to smtp messages
* fix: grpc reply_to_address min 0 and js var name
* fix: add missing translations
* fix merge and linting
---------
Co-authored-by: Livio Spring
---
cmd/defaults.yaml | 9 +-
.../notification-settings.component.html | 13 ++-
.../notification-settings.component.ts | 7 ++
console/src/assets/i18n/bg.json | 1 +
console/src/assets/i18n/de.json | 1 +
console/src/assets/i18n/en.json | 1 +
console/src/assets/i18n/es.json | 1 +
console/src/assets/i18n/fr.json | 1 +
console/src/assets/i18n/it.json | 1 +
console/src/assets/i18n/ja.json | 1 +
console/src/assets/i18n/mk.json | 1 +
console/src/assets/i18n/pl.json | 1 +
console/src/assets/i18n/pt.json | 1 +
console/src/assets/i18n/zh.json | 1 +
docs/docs/self-hosting/manage/production.md | 1 +
.../api/grpc/admin/iam_settings_converter.go | 27 ++---
internal/command/instance.go | 1 +
.../command/instance_smtp_config_model.go | 25 +++--
internal/command/smtp.go | 15 ++-
internal/command/smtp_test.go | 101 ++++++++++++++++--
.../notification/channels/smtp/channel.go | 15 +--
internal/notification/channels/smtp/config.go | 9 +-
internal/notification/handlers/config_smtp.go | 7 +-
internal/notification/messages/email.go | 4 +
internal/query/projection/smtp.go | 34 +++---
internal/query/projection/smtp_test.go | 14 ++-
internal/query/smtp.go | 19 ++--
internal/query/smtp_test.go | 50 +++++----
internal/repository/instance/smtp_config.go | 44 +++++---
proto/zitadel/admin.proto | 16 +++
proto/zitadel/settings.proto | 5 +
31 files changed, 307 insertions(+), 120 deletions(-)
diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml
index 0fa789baf1..10424663fe 100644
--- a/cmd/defaults.yaml
+++ b/cmd/defaults.yaml
@@ -371,10 +371,10 @@ SystemDefaults:
MachineKeySize: 2048 # ZITADEL_SYSTEMDEFAULTS_SECRETGENERATORS_MACHINEKEYSIZE
ApplicationKeySize: 2048 # ZITADEL_SYSTEMDEFAULTS_SECRETGENERATORS_APPLICATIONKEYSIZE
PasswordHasher:
- # Set hasher configuration for user passwords.
- # Passwords previously hashed with a different algorithm
- # or cost are automatically re-hashed using this config,
- # upon password validation or update.
+ # Set hasher configuration for user passwords.
+ # Passwords previously hashed with a different algorithm
+ # or cost are automatically re-hashed using this config,
+ # upon password validation or update.
Hasher:
Algorithm: "bcrypt" # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_ALGORITHM
Cost: 14 # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_COST
@@ -688,6 +688,7 @@ DefaultInstance:
# If the host of the sender is different from ExternalDomain set DefaultInstance.DomainPolicy.SMTPSenderAddressMatchesInstanceDomain to false
From: # ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_FROM
FromName: # ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_FROMNAME
+ ReplyToAddress: # ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_REPLYTOADDRESS
MessageTexts:
- MessageTextType: InitCode
Language: de
diff --git a/console/src/app/modules/policies/notification-settings/notification-settings.component.html b/console/src/app/modules/policies/notification-settings/notification-settings.component.html
index f01e1ddac6..5895eb58f5 100644
--- a/console/src/app/modules/policies/notification-settings/notification-settings.component.html
+++ b/console/src/app/modules/policies/notification-settings/notification-settings.component.html
@@ -15,12 +15,17 @@
+