feat(notification): use event worker pool (#8962)

# Which Problems Are Solved

The current handling of notification follows the same pattern as all
other projections:
Created events are handled sequentially (based on "position") by a
handler. During the process, a lot of information is aggregated (user,
texts, templates, ...).
This leads to back pressure on the projection since the handling of
events might take longer than the time before a new event (to be
handled) is created.

# How the Problems Are Solved

- The current user notification handler creates separate notification
events based on the user / session events.
- These events contain all the present and required information
including the userID.
- These notification events get processed by notification workers, which
gather the necessary information (recipient address, texts, templates)
to send out these notifications.
- If a notification fails, a retry event is created based on the current
notification request including the current state of the user (this
prevents race conditions, where a user is changed in the meantime and
the notification already gets the new state).
- The retry event will be handled after a backoff delay. This delay
increases with every attempt.
- If the configured amount of attempts is reached or the message expired
(based on config), a cancel event is created, letting the workers know,
the notification must no longer be handled.
- In case of successful send, a sent event is created for the
notification aggregate and the existing "sent" events for the user /
session object is stored.
- The following is added to the defaults.yaml to allow configuration of
the notification workers:
```yaml

Notifications:
  # The amount of workers processing the notification request events.
  # If set to 0, no notification request events will be handled. This can be useful when running in
  # multi binary / pod setup and allowing only certain executables to process the events.
  Workers: 1 # ZITADEL_NOTIFIACATIONS_WORKERS
  # The amount of events a single worker will process in a run.
  BulkLimit: 10 # ZITADEL_NOTIFIACATIONS_BULKLIMIT
  # Time interval between scheduled notifications for request events
  RequeueEvery: 2s # ZITADEL_NOTIFIACATIONS_REQUEUEEVERY
  # The amount of workers processing the notification retry events.
  # If set to 0, no notification retry events will be handled. This can be useful when running in
  # multi binary / pod setup and allowing only certain executables to process the events.
  RetryWorkers: 1 # ZITADEL_NOTIFIACATIONS_RETRYWORKERS
  # Time interval between scheduled notifications for retry events
  RetryRequeueEvery: 2s # ZITADEL_NOTIFIACATIONS_RETRYREQUEUEEVERY
  # Only instances are projected, for which at least a projection-relevant event exists within the timeframe
  # from HandleActiveInstances duration in the past until the projection's current time
  # If set to 0 (default), every instance is always considered active
  HandleActiveInstances: 0s # ZITADEL_NOTIFIACATIONS_HANDLEACTIVEINSTANCES
  # The maximum duration a transaction remains open
  # before it spots left folding additional events
  # and updates the table.
  TransactionDuration: 1m # ZITADEL_NOTIFIACATIONS_TRANSACTIONDURATION
  # Automatically cancel the notification after the amount of failed attempts
  MaxAttempts: 3 # ZITADEL_NOTIFIACATIONS_MAXATTEMPTS
  # Automatically cancel the notification if it cannot be handled within a specific time
  MaxTtl: 5m  # ZITADEL_NOTIFIACATIONS_MAXTTL
  # Failed attempts are retried after a confogired delay (with exponential backoff).
  # Set a minimum and maximum delay and a factor for the backoff
  MinRetryDelay: 1s  # ZITADEL_NOTIFIACATIONS_MINRETRYDELAY
  MaxRetryDelay: 20s # ZITADEL_NOTIFIACATIONS_MAXRETRYDELAY
  # Any factor below 1 will be set to 1
  RetryDelayFactor: 1.5 # ZITADEL_NOTIFIACATIONS_RETRYDELAYFACTOR
```


# Additional Changes

None

# Additional Context

- closes #8931
This commit is contained in:
Livio Spring
2024-11-27 16:01:17 +01:00
committed by GitHub
parent 4413efd82c
commit 8537805ea5
45 changed files with 4005 additions and 2158 deletions

View File

@@ -2,13 +2,19 @@ package handlers
import (
"context"
"database/sql"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/notification/senders"
"github.com/zitadel/zitadel/internal/repository/milestone"
"github.com/zitadel/zitadel/internal/repository/quota"
)
type Commands interface {
RequestNotification(ctx context.Context, instanceID string, request *command.NotificationRequest) error
NotificationCanceled(ctx context.Context, tx *sql.Tx, id, resourceOwner string, err error) error
NotificationRetryRequested(ctx context.Context, tx *sql.Tx, id, resourceOwner string, request *command.NotificationRetryRequest, err error) error
NotificationSent(ctx context.Context, tx *sql.Tx, id, instanceID string) error
HumanInitCodeSent(ctx context.Context, orgID, userID string) error
HumanEmailVerificationCodeSent(ctx context.Context, orgID, userID string) error
PasswordCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error

View File

@@ -23,6 +23,9 @@ func (n *NotificationQueries) GetActiveEmailConfig(ctx context.Context) (*email.
Description: config.Description,
}
if config.SMTPConfig != nil {
if config.SMTPConfig.Password == nil {
return nil, zerrors.ThrowNotFound(err, "QUERY-Wrs3gw", "Errors.SMTPConfig.NotFound")
}
password, err := crypto.DecryptString(config.SMTPConfig.Password, n.SMTPPasswordCrypto)
if err != nil {
return nil, err

View File

@@ -24,6 +24,9 @@ func (n *NotificationQueries) GetActiveSMSConfig(ctx context.Context) (*sms.Conf
Description: config.Description,
}
if config.TwilioConfig != nil {
if config.TwilioConfig.Token == nil {
return nil, zerrors.ThrowNotFound(err, "QUERY-SFefsd", "Errors.SMS.Twilio.NotFound")
}
token, err := crypto.DecryptString(config.TwilioConfig.Token, n.SMSTokenCrypto)
if err != nil {
return nil, err

View File

@@ -14,6 +14,10 @@ func HandlerContext(event *eventstore.Aggregate) context.Context {
return authz.SetCtxData(ctx, authz.CtxData{UserID: NotifyUserID, OrgID: event.ResourceOwner})
}
func ContextWithNotifier(ctx context.Context, aggregate *eventstore.Aggregate) context.Context {
return authz.SetCtxData(ctx, authz.CtxData{UserID: NotifyUserID, OrgID: aggregate.ResourceOwner})
}
func (n *NotificationQueries) HandlerContext(event *eventstore.Aggregate) (context.Context, error) {
ctx := context.Background()
instance, err := n.InstanceByID(ctx, event.InstanceID)

View File

@@ -11,8 +11,10 @@ package mock
import (
context "context"
sql "database/sql"
reflect "reflect"
command "github.com/zitadel/zitadel/internal/command"
senders "github.com/zitadel/zitadel/internal/notification/senders"
milestone "github.com/zitadel/zitadel/internal/repository/milestone"
quota "github.com/zitadel/zitadel/internal/repository/quota"
@@ -155,6 +157,48 @@ func (mr *MockCommandsMockRecorder) MilestonePushed(ctx, instanceID, msType, end
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), ctx, instanceID, msType, endpoints)
}
// NotificationCanceled mocks base method.
func (m *MockCommands) NotificationCanceled(ctx context.Context, tx *sql.Tx, id, resourceOwner string, err error) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "NotificationCanceled", ctx, tx, id, resourceOwner, err)
ret0, _ := ret[0].(error)
return ret0
}
// NotificationCanceled indicates an expected call of NotificationCanceled.
func (mr *MockCommandsMockRecorder) NotificationCanceled(ctx, tx, id, resourceOwner, err any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationCanceled", reflect.TypeOf((*MockCommands)(nil).NotificationCanceled), ctx, tx, id, resourceOwner, err)
}
// NotificationRetryRequested mocks base method.
func (m *MockCommands) NotificationRetryRequested(ctx context.Context, tx *sql.Tx, id, resourceOwner string, request *command.NotificationRetryRequest, err error) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "NotificationRetryRequested", ctx, tx, id, resourceOwner, request, err)
ret0, _ := ret[0].(error)
return ret0
}
// NotificationRetryRequested indicates an expected call of NotificationRetryRequested.
func (mr *MockCommandsMockRecorder) NotificationRetryRequested(ctx, tx, id, resourceOwner, request, err any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationRetryRequested", reflect.TypeOf((*MockCommands)(nil).NotificationRetryRequested), ctx, tx, id, resourceOwner, request, err)
}
// NotificationSent mocks base method.
func (m *MockCommands) NotificationSent(ctx context.Context, tx *sql.Tx, id, instanceID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "NotificationSent", ctx, tx, id, instanceID)
ret0, _ := ret[0].(error)
return ret0
}
// NotificationSent indicates an expected call of NotificationSent.
func (mr *MockCommandsMockRecorder) NotificationSent(ctx, tx, id, instanceID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationSent", reflect.TypeOf((*MockCommands)(nil).NotificationSent), ctx, tx, id, instanceID)
}
// OTPEmailSent mocks base method.
func (m *MockCommands) OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error {
m.ctrl.T.Helper()
@@ -211,6 +255,20 @@ func (mr *MockCommandsMockRecorder) PasswordCodeSent(ctx, orgID, userID, generat
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), ctx, orgID, userID, generatorInfo)
}
// RequestNotification mocks base method.
func (m *MockCommands) RequestNotification(ctx context.Context, instanceID string, request *command.NotificationRequest) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RequestNotification", ctx, instanceID, request)
ret0, _ := ret[0].(error)
return ret0
}
// RequestNotification indicates an expected call of RequestNotification.
func (mr *MockCommandsMockRecorder) RequestNotification(ctx, instanceID, request any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestNotification", reflect.TypeOf((*MockCommands)(nil).RequestNotification), ctx, instanceID, request)
}
// UsageNotificationSent mocks base method.
func (m *MockCommands) UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error {
m.ctrl.T.Helper()

View File

@@ -0,0 +1,515 @@
package handlers
import (
"context"
"database/sql"
"errors"
"math/rand/v2"
"slices"
"strings"
"time"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/call"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/notification/channels"
"github.com/zitadel/zitadel/internal/notification/senders"
"github.com/zitadel/zitadel/internal/notification/types"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/notification"
)
const (
Domain = "Domain"
Code = "Code"
OTP = "OTP"
)
type NotificationWorker struct {
commands Commands
queries *NotificationQueries
es *eventstore.Eventstore
client *database.DB
channels types.ChannelChains
config WorkerConfig
now nowFunc
backOff func(current time.Duration) time.Duration
}
type WorkerConfig struct {
Workers uint8
BulkLimit uint16
RequeueEvery time.Duration
RetryWorkers uint8
RetryRequeueEvery time.Duration
HandleActiveInstances time.Duration
TransactionDuration time.Duration
MaxAttempts uint8
MaxTtl time.Duration
MinRetryDelay time.Duration
MaxRetryDelay time.Duration
RetryDelayFactor float32
}
// nowFunc makes [time.Now] mockable
type nowFunc func() time.Time
type Sent func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error
var sentHandlers map[eventstore.EventType]Sent
func RegisterSentHandler(eventType eventstore.EventType, sent Sent) {
if sentHandlers == nil {
sentHandlers = make(map[eventstore.EventType]Sent)
}
sentHandlers[eventType] = sent
}
func NewNotificationWorker(
config WorkerConfig,
commands Commands,
queries *NotificationQueries,
es *eventstore.Eventstore,
client *database.DB,
channels types.ChannelChains,
) *NotificationWorker {
// make sure the delay does not get less
if config.RetryDelayFactor < 1 {
config.RetryDelayFactor = 1
}
w := &NotificationWorker{
config: config,
commands: commands,
queries: queries,
es: es,
client: client,
channels: channels,
now: time.Now,
}
w.backOff = w.exponentialBackOff
return w
}
func (w *NotificationWorker) Start(ctx context.Context) {
for i := 0; i < int(w.config.Workers); i++ {
go w.schedule(ctx, i, false)
}
for i := 0; i < int(w.config.RetryWorkers); i++ {
go w.schedule(ctx, i, true)
}
}
func (w *NotificationWorker) reduceNotificationRequested(ctx context.Context, tx *sql.Tx, event *notification.RequestedEvent) (err error) {
ctx = ContextWithNotifier(ctx, event.Aggregate())
// if the notification is too old, we can directly cancel
if event.CreatedAt().Add(w.config.MaxTtl).Before(w.now()) {
return w.commands.NotificationCanceled(ctx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, nil)
}
// Get the notify user first, so if anything fails afterward we have the current state of the user
// and can pass that to the retry request.
notifyUser, err := w.queries.GetNotifyUserByID(ctx, true, event.UserID)
if err != nil {
return err
}
// The domain claimed event requires the domain as argument, but lacks the user when creating the request event.
// Since we set it into the request arguments, it will be passed into a potential retry event.
if event.RequiresPreviousDomain && event.Request.Args != nil && event.Request.Args.Domain == "" {
index := strings.LastIndex(notifyUser.LastEmail, "@")
event.Request.Args.Domain = notifyUser.LastEmail[index+1:]
}
err = w.sendNotification(ctx, tx, event.Request, notifyUser, event)
if err == nil {
return nil
}
// if retries are disabled or if the error explicitly specifies, we cancel the notification
if w.config.MaxAttempts <= 1 || errors.Is(err, &channels.CancelError{}) {
return w.commands.NotificationCanceled(ctx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, err)
}
// otherwise we retry after a backoff delay
return w.commands.NotificationRetryRequested(
ctx,
tx,
event.Aggregate().ID,
event.Aggregate().ResourceOwner,
notificationEventToRequest(event.Request, notifyUser, w.backOff(0)),
err,
)
}
func (w *NotificationWorker) reduceNotificationRetry(ctx context.Context, tx *sql.Tx, event *notification.RetryRequestedEvent) (err error) {
ctx = ContextWithNotifier(ctx, event.Aggregate())
// if the notification is too old, we can directly cancel
if event.CreatedAt().Add(w.config.MaxTtl).Before(w.now()) {
return w.commands.NotificationCanceled(ctx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, err)
}
if event.CreatedAt().Add(event.BackOff).After(w.now()) {
return nil
}
err = w.sendNotification(ctx, tx, event.Request, event.NotifyUser, event)
if err == nil {
return nil
}
// if the max attempts are reached or if the error explicitly specifies, we cancel the notification
if event.Sequence() >= uint64(w.config.MaxAttempts) || errors.Is(err, &channels.CancelError{}) {
return w.commands.NotificationCanceled(ctx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, err)
}
// otherwise we retry after a backoff delay
return w.commands.NotificationRetryRequested(ctx, tx, event.Aggregate().ID, event.Aggregate().ResourceOwner, notificationEventToRequest(
event.Request,
event.NotifyUser,
w.backOff(event.BackOff),
), err)
}
func (w *NotificationWorker) sendNotification(ctx context.Context, tx *sql.Tx, request notification.Request, notifyUser *query.NotifyUser, e eventstore.Event) error {
ctx, err := enrichCtx(ctx, request.TriggeredAtOrigin)
if err != nil {
err := w.commands.NotificationCanceled(ctx, tx, e.Aggregate().ID, e.Aggregate().ResourceOwner, err)
logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "notification", e.Aggregate().ID).
OnError(err).Error("could not cancel notification")
return nil
}
// check early that a "sent" handler exists, otherwise we can cancel early
sentHandler, ok := sentHandlers[request.EventType]
if !ok {
err := w.commands.NotificationCanceled(ctx, tx, e.Aggregate().ID, e.Aggregate().ResourceOwner, err)
logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "notification", e.Aggregate().ID).
OnError(err).Errorf(`no "sent" handler registered for %s`, request.EventType)
return nil
}
var code string
if request.Code != nil {
code, err = crypto.DecryptString(request.Code, w.queries.UserDataCrypto)
if err != nil {
return err
}
}
colors, err := w.queries.ActiveLabelPolicyByOrg(ctx, request.UserResourceOwner, false)
if err != nil {
return err
}
translator, err := w.queries.GetTranslatorWithOrgTexts(ctx, request.UserResourceOwner, request.MessageType)
if err != nil {
return err
}
generatorInfo := new(senders.CodeGeneratorInfo)
var notify types.Notify
switch request.NotificationType {
case domain.NotificationTypeEmail:
template, err := w.queries.MailTemplateByOrg(ctx, notifyUser.ResourceOwner, false)
if err != nil {
return err
}
notify = types.SendEmail(ctx, w.channels, string(template.Template), translator, notifyUser, colors, e)
case domain.NotificationTypeSms:
notify = types.SendSMS(ctx, w.channels, translator, notifyUser, colors, e, generatorInfo)
}
args := request.Args.ToMap()
args[Code] = code
// existing notifications use `OTP` as argument for the code
if request.IsOTP {
args[OTP] = code
}
if err := notify(request.URLTemplate, args, request.MessageType, request.UnverifiedNotificationChannel); err != nil {
return err
}
err = w.commands.NotificationSent(ctx, tx, e.Aggregate().ID, e.Aggregate().ResourceOwner)
if err != nil {
// In case the notification event cannot be pushed, we most likely cannot create a retry or cancel event.
// Therefore, we'll only log the error and also do not need to try to push to the user / session.
logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "notification", e.Aggregate().ID).
OnError(err).Error("could not set sent notification event")
return nil
}
err = sentHandler(ctx, w.commands, request.NotificationAggregateID(), request.NotificationAggregateResourceOwner(), generatorInfo, args)
logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID(), "notification", e.Aggregate().ID).
OnError(err).Error("could not set notification event on aggregate")
return nil
}
func (w *NotificationWorker) exponentialBackOff(current time.Duration) time.Duration {
if current >= w.config.MaxRetryDelay {
return w.config.MaxRetryDelay
}
if current < w.config.MinRetryDelay {
current = w.config.MinRetryDelay
}
t := time.Duration(rand.Int64N(int64(w.config.RetryDelayFactor*float32(current.Nanoseconds()))-current.Nanoseconds()) + current.Nanoseconds())
if t > w.config.MaxRetryDelay {
return w.config.MaxRetryDelay
}
return t
}
func notificationEventToRequest(e notification.Request, notifyUser *query.NotifyUser, backoff time.Duration) *command.NotificationRetryRequest {
return &command.NotificationRetryRequest{
NotificationRequest: command.NotificationRequest{
UserID: e.UserID,
UserResourceOwner: e.UserResourceOwner,
TriggerOrigin: e.TriggeredAtOrigin,
URLTemplate: e.URLTemplate,
Code: e.Code,
CodeExpiry: e.CodeExpiry,
EventType: e.EventType,
NotificationType: e.NotificationType,
MessageType: e.MessageType,
UnverifiedNotificationChannel: e.UnverifiedNotificationChannel,
Args: e.Args,
AggregateID: e.AggregateID,
AggregateResourceOwner: e.AggregateResourceOwner,
IsOTP: e.IsOTP,
},
BackOff: backoff,
NotifyUser: notifyUser,
}
}
func (w *NotificationWorker) schedule(ctx context.Context, workerID int, retry bool) {
t := time.NewTimer(0)
for {
select {
case <-ctx.Done():
t.Stop()
w.log(workerID, retry).Info("scheduler stopped")
return
case <-t.C:
instances, err := w.queryInstances(ctx, retry)
w.log(workerID, retry).OnError(err).Error("unable to query instances")
w.triggerInstances(call.WithTimestamp(ctx), instances, workerID, retry)
if retry {
t.Reset(w.config.RetryRequeueEvery)
continue
}
t.Reset(w.config.RequeueEvery)
}
}
}
func (w *NotificationWorker) log(workerID int, retry bool) *logging.Entry {
return logging.WithFields("notification worker", workerID, "retries", retry)
}
func (w *NotificationWorker) queryInstances(ctx context.Context, retry bool) ([]string, error) {
if w.config.HandleActiveInstances == 0 {
return w.existingInstances(ctx)
}
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs).
AwaitOpenTransactions().
AllowTimeTravel().
CreationDateAfter(w.now().Add(-1 * w.config.HandleActiveInstances))
maxAge := w.config.RequeueEvery
if retry {
maxAge = w.config.RetryRequeueEvery
}
return w.es.InstanceIDs(ctx, maxAge, false, query)
}
func (w *NotificationWorker) existingInstances(ctx context.Context) ([]string, error) {
ai := existingInstances{}
if err := w.es.FilterToQueryReducer(ctx, &ai); err != nil {
return nil, err
}
return ai, nil
}
func (w *NotificationWorker) triggerInstances(ctx context.Context, instances []string, workerID int, retry bool) {
for _, instance := range instances {
instanceCtx := authz.WithInstanceID(ctx, instance)
err := w.trigger(instanceCtx, workerID, retry)
w.log(workerID, retry).WithField("instance", instance).OnError(err).Info("trigger failed")
}
}
func (w *NotificationWorker) trigger(ctx context.Context, workerID int, retry bool) (err error) {
txCtx := ctx
if w.config.TransactionDuration > 0 {
var cancel, cancelTx func()
txCtx, cancelTx = context.WithCancel(ctx)
defer cancelTx()
ctx, cancel = context.WithTimeout(ctx, w.config.TransactionDuration)
defer cancel()
}
tx, err := w.client.BeginTx(txCtx, nil)
if err != nil {
return err
}
defer func() {
err = database.CloseTransaction(tx, err)
}()
events, err := w.searchEvents(ctx, tx, retry)
if err != nil {
return err
}
// If there aren't any events or no unlocked event terminate early and start a new run.
if len(events) == 0 {
return nil
}
w.log(workerID, retry).
WithField("instanceID", authz.GetInstance(ctx).InstanceID()).
WithField("events", len(events)).
Info("handling notification events")
for _, event := range events {
var err error
switch e := event.(type) {
case *notification.RequestedEvent:
w.createSavepoint(ctx, tx, event, workerID, retry)
err = w.reduceNotificationRequested(ctx, tx, e)
case *notification.RetryRequestedEvent:
w.createSavepoint(ctx, tx, event, workerID, retry)
err = w.reduceNotificationRetry(ctx, tx, e)
}
if err != nil {
w.log(workerID, retry).OnError(err).
WithField("instanceID", authz.GetInstance(ctx).InstanceID()).
WithField("notificationID", event.Aggregate().ID).
WithField("sequence", event.Sequence()).
WithField("type", event.Type()).
Error("could not push notification event")
w.rollbackToSavepoint(ctx, tx, event, workerID, retry)
}
}
return nil
}
func (w *NotificationWorker) latestRetries(events []eventstore.Event) {
for i := len(events) - 1; i > 0; i-- {
// since we delete during the iteration, we need to make sure we don't panic
if len(events) <= i {
continue
}
// delete all the previous retries of the same notification
events = slices.DeleteFunc(events, func(e eventstore.Event) bool {
return e.Aggregate().ID == events[i].Aggregate().ID &&
e.Sequence() < events[i].Sequence()
})
}
}
func (w *NotificationWorker) createSavepoint(ctx context.Context, tx *sql.Tx, event eventstore.Event, workerID int, retry bool) {
_, err := tx.ExecContext(ctx, "SAVEPOINT notification_send")
w.log(workerID, retry).OnError(err).
WithField("instanceID", authz.GetInstance(ctx).InstanceID()).
WithField("notificationID", event.Aggregate().ID).
WithField("sequence", event.Sequence()).
WithField("type", event.Type()).
Error("could not create savepoint for notification event")
}
func (w *NotificationWorker) rollbackToSavepoint(ctx context.Context, tx *sql.Tx, event eventstore.Event, workerID int, retry bool) {
_, err := tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT notification_send")
w.log(workerID, retry).OnError(err).
WithField("instanceID", authz.GetInstance(ctx).InstanceID()).
WithField("notificationID", event.Aggregate().ID).
WithField("sequence", event.Sequence()).
WithField("type", event.Type()).
Error("could not rollback to savepoint for notification event")
}
func (w *NotificationWorker) searchEvents(ctx context.Context, tx *sql.Tx, retry bool) ([]eventstore.Event, error) {
if retry {
return w.searchRetryEvents(ctx, tx)
}
// query events and lock them for update (with skip locked)
searchQuery := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
LockRowsDuringTx(tx, eventstore.LockOptionSkipLocked).
// Messages older than the MaxTTL, we can be ignored.
// The first attempt of a retry might still be older than the TTL and needs to be filtered out later on.
CreationDateAfter(w.now().Add(-1*w.config.MaxTtl)).
Limit(uint64(w.config.BulkLimit)).
AddQuery().
AggregateTypes(notification.AggregateType).
EventTypes(notification.RequestedType).
Builder().
ExcludeAggregateIDs().
EventTypes(notification.RetryRequestedType, notification.CanceledType, notification.SentType).
Builder()
//nolint:staticcheck
return w.es.Filter(ctx, searchQuery)
}
func (w *NotificationWorker) searchRetryEvents(ctx context.Context, tx *sql.Tx) ([]eventstore.Event, error) {
// query events and lock them for update (with skip locked)
searchQuery := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
LockRowsDuringTx(tx, eventstore.LockOptionSkipLocked).
// Messages older than the MaxTTL, we can be ignored.
// The first attempt of a retry might still be older than the TTL and needs to be filtered out later on.
CreationDateAfter(w.now().Add(-1*w.config.MaxTtl)).
AddQuery().
AggregateTypes(notification.AggregateType).
EventTypes(notification.RetryRequestedType).
Builder().
ExcludeAggregateIDs().
EventTypes(notification.CanceledType, notification.SentType).
Builder()
//nolint:staticcheck
events, err := w.es.Filter(ctx, searchQuery)
if err != nil {
return nil, err
}
w.latestRetries(events)
return events, nil
}
type existingInstances []string
// AppendEvents implements eventstore.QueryReducer.
func (ai *existingInstances) AppendEvents(events ...eventstore.Event) {
for _, event := range events {
switch event.Type() {
case instance.InstanceAddedEventType:
*ai = append(*ai, event.Aggregate().InstanceID)
case instance.InstanceRemovedEventType:
*ai = slices.DeleteFunc(*ai, func(s string) bool {
return s == event.Aggregate().InstanceID
})
}
}
}
// Query implements eventstore.QueryReducer.
func (*existingInstances) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
AddQuery().
AggregateTypes(instance.AggregateType).
EventTypes(
instance.InstanceAddedEventType,
instance.InstanceRemovedEventType,
).
Builder()
}
// Reduce implements eventstore.QueryReducer.
// reduce is not used as events are reduced during AppendEvents
func (*existingInstances) Reduce() error {
return nil
}

View File

@@ -0,0 +1,963 @@
package handlers
import (
"context"
"database/sql"
"errors"
"fmt"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"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/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository"
es_repo_mock "github.com/zitadel/zitadel/internal/eventstore/repository/mock"
"github.com/zitadel/zitadel/internal/notification/channels/email"
channel_mock "github.com/zitadel/zitadel/internal/notification/channels/mock"
"github.com/zitadel/zitadel/internal/notification/channels/sms"
"github.com/zitadel/zitadel/internal/notification/channels/smtp"
"github.com/zitadel/zitadel/internal/notification/channels/twilio"
"github.com/zitadel/zitadel/internal/notification/handlers/mock"
"github.com/zitadel/zitadel/internal/notification/messages"
"github.com/zitadel/zitadel/internal/notification/senders"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/repository/notification"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
)
const (
notificationID = "notificationID"
)
func Test_userNotifier_reduceNotificationRequested(t *testing.T) {
testNow := time.Now
testBackOff := func(current time.Duration) time.Duration {
return time.Second
}
sendError := errors.New("send error")
tests := []struct {
name string
test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fieldsWorker, argsWorker, wantWorker)
}{
{
name: "too old",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
codeAlg, code := cryptoValue(t, ctrl, "testcode")
commands.EXPECT().NotificationCanceled(gomock.Any(), gomock.Any(), notificationID, instanceID, nil).Return(nil)
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: codeAlg,
now: testNow,
},
argsWorker{
event: &notification.RequestedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{
InstanceID: instanceID,
AggregateID: notificationID,
ResourceOwner: sql.NullString{String: instanceID},
CreationDate: time.Now().Add(-1 * time.Hour),
Typ: notification.RequestedType,
}),
Request: notification.Request{
UserID: userID,
UserResourceOwner: orgID,
AggregateID: "",
AggregateResourceOwner: "",
TriggeredAtOrigin: eventOrigin,
EventType: user.HumanInviteCodeAddedType,
MessageType: domain.InviteUserMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
CodeExpiry: 1 * time.Hour,
Code: code,
UnverifiedNotificationChannel: true,
IsOTP: false,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
ApplicationName: "APP",
},
},
},
}, w
},
},
{
name: "send ok (email)",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
givenTemplate := "{{.LogoURL}}"
expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL)
w.message = &messages.Email{
Recipients: []string{lastEmail},
Subject: "Invitation to APP",
Content: expectContent,
}
codeAlg, code := cryptoValue(t, ctrl, "testcode")
expectTemplateWithNotifyUserQueries(queries, givenTemplate)
commands.EXPECT().NotificationSent(gomock.Any(), gomock.Any(), notificationID, instanceID).Return(nil)
commands.EXPECT().InviteCodeSent(gomock.Any(), orgID, userID).Return(nil)
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: codeAlg,
now: testNow,
},
argsWorker{
event: &notification.RequestedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{
InstanceID: instanceID,
AggregateID: notificationID,
ResourceOwner: sql.NullString{String: instanceID},
CreationDate: time.Now().UTC(),
Typ: notification.RequestedType,
}),
Request: notification.Request{
UserID: userID,
UserResourceOwner: orgID,
AggregateID: "",
AggregateResourceOwner: "",
TriggeredAtOrigin: eventOrigin,
EventType: user.HumanInviteCodeAddedType,
MessageType: domain.InviteUserMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
CodeExpiry: 1 * time.Hour,
Code: code,
UnverifiedNotificationChannel: true,
IsOTP: false,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
ApplicationName: "APP",
},
},
},
}, w
},
},
{
name: "send ok (sms with external provider)",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
expiry := 0 * time.Hour
testCode := ""
expectContent := fmt.Sprintf(`%[1]s is your one-time password for %[2]s. Use it within the next %[3]s.
@%[2]s #%[1]s`, testCode, eventOriginDomain, expiry)
w.messageSMS = &messages.SMS{
SenderPhoneNumber: "senderNumber",
RecipientPhoneNumber: verifiedPhone,
Content: expectContent,
}
codeAlg, code := cryptoValue(t, ctrl, testCode)
expectTemplateWithNotifyUserQueriesSMS(queries)
commands.EXPECT().NotificationSent(gomock.Any(), gomock.Any(), notificationID, instanceID).Return(nil)
commands.EXPECT().OTPSMSSent(gomock.Any(), sessionID, instanceID, &senders.CodeGeneratorInfo{
ID: smsProviderID,
VerificationID: verificationID,
}).Return(nil)
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: codeAlg,
now: testNow,
},
argsWorker{
event: &notification.RequestedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{
InstanceID: instanceID,
AggregateID: notificationID,
ResourceOwner: sql.NullString{String: instanceID},
CreationDate: time.Now().UTC(),
Typ: notification.RequestedType,
}),
Request: notification.Request{
UserID: userID,
UserResourceOwner: orgID,
AggregateID: sessionID,
AggregateResourceOwner: instanceID,
TriggeredAtOrigin: eventOrigin,
EventType: session.OTPSMSChallengedType,
MessageType: domain.VerifySMSOTPMessageType,
NotificationType: domain.NotificationTypeSms,
URLTemplate: "",
CodeExpiry: expiry,
Code: code,
UnverifiedNotificationChannel: false,
IsOTP: true,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
Origin: eventOrigin,
Domain: eventOriginDomain,
Expiry: expiry,
},
},
},
}, w
},
},
{
name: "previous domain",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
givenTemplate := "{{.LogoURL}}"
expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL)
w.message = &messages.Email{
Recipients: []string{verifiedEmail},
Subject: "Domain has been claimed",
Content: expectContent,
}
expectTemplateWithNotifyUserQueries(queries, givenTemplate)
commands.EXPECT().NotificationSent(gomock.Any(), gomock.Any(), notificationID, instanceID).Return(nil)
commands.EXPECT().UserDomainClaimedSent(gomock.Any(), orgID, userID).Return(nil)
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: nil,
now: testNow,
},
argsWorker{
event: &notification.RequestedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{
InstanceID: instanceID,
AggregateID: notificationID,
ResourceOwner: sql.NullString{String: instanceID},
CreationDate: time.Now().UTC(),
Typ: notification.RequestedType,
}),
Request: notification.Request{
UserID: userID,
UserResourceOwner: orgID,
AggregateID: "",
AggregateResourceOwner: "",
TriggeredAtOrigin: eventOrigin,
EventType: user.UserDomainClaimedType,
MessageType: domain.DomainClaimedMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: login.LoginLink(eventOrigin, orgID),
CodeExpiry: 0,
Code: nil,
UnverifiedNotificationChannel: false,
IsOTP: false,
RequiresPreviousDomain: true,
Args: &domain.NotificationArguments{
TempUsername: "tempUsername",
},
},
},
}, w
},
},
{
name: "send failed, retry",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
givenTemplate := "{{.LogoURL}}"
expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL)
w.message = &messages.Email{
Recipients: []string{lastEmail},
Subject: "Invitation to APP",
Content: expectContent,
}
w.sendError = sendError
codeAlg, code := cryptoValue(t, ctrl, "testcode")
expectTemplateWithNotifyUserQueries(queries, givenTemplate)
commands.EXPECT().NotificationRetryRequested(gomock.Any(), gomock.Any(), notificationID, instanceID,
&command.NotificationRetryRequest{
NotificationRequest: command.NotificationRequest{
UserID: userID,
UserResourceOwner: orgID,
AggregateID: "",
AggregateResourceOwner: "",
TriggerOrigin: eventOrigin,
EventType: user.HumanInviteCodeAddedType,
MessageType: domain.InviteUserMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
CodeExpiry: 1 * time.Hour,
Code: code,
UnverifiedNotificationChannel: true,
IsOTP: false,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
ApplicationName: "APP",
},
},
BackOff: 1 * time.Second,
NotifyUser: &query.NotifyUser{
ID: userID,
ResourceOwner: orgID,
LastEmail: lastEmail,
VerifiedEmail: verifiedEmail,
PreferredLoginName: preferredLoginName,
LastPhone: lastPhone,
VerifiedPhone: verifiedPhone,
},
},
sendError,
).Return(nil)
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: codeAlg,
now: testNow,
backOff: testBackOff,
maxAttempts: 2,
},
argsWorker{
event: &notification.RequestedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{
InstanceID: instanceID,
AggregateID: notificationID,
ResourceOwner: sql.NullString{String: instanceID},
CreationDate: time.Now().UTC(),
Typ: notification.RequestedType,
}),
Request: notification.Request{
UserID: userID,
UserResourceOwner: orgID,
AggregateID: "",
AggregateResourceOwner: "",
TriggeredAtOrigin: eventOrigin,
EventType: user.HumanInviteCodeAddedType,
MessageType: domain.InviteUserMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
CodeExpiry: 1 * time.Hour,
Code: code,
UnverifiedNotificationChannel: true,
IsOTP: false,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
ApplicationName: "APP",
},
},
},
}, w
},
},
{
name: "send failed (max attempts), cancel",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
givenTemplate := "{{.LogoURL}}"
expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL)
w.message = &messages.Email{
Recipients: []string{lastEmail},
Subject: "Invitation to APP",
Content: expectContent,
}
w.sendError = sendError
codeAlg, code := cryptoValue(t, ctrl, "testcode")
expectTemplateWithNotifyUserQueries(queries, givenTemplate)
commands.EXPECT().NotificationCanceled(gomock.Any(), gomock.Any(), notificationID, instanceID, sendError).Return(nil)
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: codeAlg,
now: testNow,
backOff: testBackOff,
maxAttempts: 1,
},
argsWorker{
event: &notification.RequestedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{
InstanceID: instanceID,
AggregateID: notificationID,
ResourceOwner: sql.NullString{String: instanceID},
CreationDate: time.Now().UTC(),
Seq: 1,
Typ: notification.RequestedType,
}),
Request: notification.Request{
UserID: userID,
UserResourceOwner: orgID,
AggregateID: "",
AggregateResourceOwner: "",
TriggeredAtOrigin: eventOrigin,
EventType: user.HumanInviteCodeAddedType,
MessageType: domain.InviteUserMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
CodeExpiry: 1 * time.Hour,
Code: code,
UnverifiedNotificationChannel: true,
IsOTP: false,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
ApplicationName: "APP",
},
},
},
}, w
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
queries := mock.NewMockQueries(ctrl)
commands := mock.NewMockCommands(ctrl)
f, a, w := tt.test(ctrl, queries, commands)
err := newNotificationWorker(t, ctrl, queries, f, a, w).reduceNotificationRequested(
authz.WithInstanceID(context.Background(), instanceID),
&sql.Tx{},
a.event.(*notification.RequestedEvent))
if w.err != nil {
w.err(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func Test_userNotifier_reduceNotificationRetry(t *testing.T) {
testNow := time.Now
testBackOff := func(current time.Duration) time.Duration {
return time.Second
}
sendError := errors.New("send error")
tests := []struct {
name string
test func(*gomock.Controller, *mock.MockQueries, *mock.MockCommands) (fieldsWorker, argsWorker, wantWorker)
}{
{
name: "too old",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
codeAlg, code := cryptoValue(t, ctrl, "testcode")
commands.EXPECT().NotificationCanceled(gomock.Any(), gomock.Any(), notificationID, instanceID, nil).Return(nil)
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: codeAlg,
now: testNow,
},
argsWorker{
event: &notification.RetryRequestedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{
InstanceID: instanceID,
AggregateID: notificationID,
ResourceOwner: sql.NullString{String: instanceID},
CreationDate: time.Now().Add(-1 * time.Hour),
Typ: notification.RequestedType,
}),
Request: notification.Request{
UserID: userID,
UserResourceOwner: orgID,
AggregateID: "",
AggregateResourceOwner: "",
TriggeredAtOrigin: eventOrigin,
EventType: user.HumanInviteCodeAddedType,
MessageType: domain.InviteUserMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
CodeExpiry: 1 * time.Hour,
Code: code,
UnverifiedNotificationChannel: true,
IsOTP: false,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
ApplicationName: "APP",
},
},
BackOff: 1 * time.Second,
NotifyUser: &query.NotifyUser{
ID: userID,
ResourceOwner: orgID,
LastEmail: lastEmail,
VerifiedEmail: verifiedEmail,
PreferredLoginName: preferredLoginName,
LastPhone: lastPhone,
VerifiedPhone: verifiedPhone,
},
},
}, w
},
},
{
name: "backoff not done",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
codeAlg, code := cryptoValue(t, ctrl, "testcode")
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: codeAlg,
now: testNow,
},
argsWorker{
event: &notification.RetryRequestedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{
InstanceID: instanceID,
AggregateID: notificationID,
ResourceOwner: sql.NullString{String: instanceID},
CreationDate: time.Now(),
Typ: notification.RequestedType,
Seq: 2,
}),
Request: notification.Request{
UserID: userID,
UserResourceOwner: orgID,
AggregateID: "",
AggregateResourceOwner: "",
TriggeredAtOrigin: eventOrigin,
EventType: user.HumanInviteCodeAddedType,
MessageType: domain.InviteUserMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
CodeExpiry: 1 * time.Hour,
Code: code,
UnverifiedNotificationChannel: true,
IsOTP: false,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
ApplicationName: "APP",
},
},
BackOff: 10 * time.Second,
NotifyUser: &query.NotifyUser{
ID: userID,
ResourceOwner: orgID,
LastEmail: lastEmail,
VerifiedEmail: verifiedEmail,
PreferredLoginName: preferredLoginName,
LastPhone: lastPhone,
VerifiedPhone: verifiedPhone,
},
},
}, w
},
},
{
name: "send ok",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
givenTemplate := "{{.LogoURL}}"
expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL)
w.message = &messages.Email{
Recipients: []string{lastEmail},
Subject: "Invitation to APP",
Content: expectContent,
}
codeAlg, code := cryptoValue(t, ctrl, "testcode")
expectTemplateQueries(queries, givenTemplate)
commands.EXPECT().NotificationSent(gomock.Any(), gomock.Any(), notificationID, instanceID).Return(nil)
commands.EXPECT().InviteCodeSent(gomock.Any(), orgID, userID).Return(nil)
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: codeAlg,
now: testNow,
maxAttempts: 3,
},
argsWorker{
event: &notification.RetryRequestedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{
InstanceID: instanceID,
AggregateID: notificationID,
ResourceOwner: sql.NullString{String: instanceID},
CreationDate: time.Now().Add(-2 * time.Second),
Typ: notification.RequestedType,
Seq: 2,
}),
Request: notification.Request{
UserID: userID,
UserResourceOwner: orgID,
AggregateID: "",
AggregateResourceOwner: "",
TriggeredAtOrigin: eventOrigin,
EventType: user.HumanInviteCodeAddedType,
MessageType: domain.InviteUserMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
CodeExpiry: 1 * time.Hour,
Code: code,
UnverifiedNotificationChannel: true,
IsOTP: false,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
ApplicationName: "APP",
},
},
BackOff: 1 * time.Second,
NotifyUser: &query.NotifyUser{
ID: userID,
ResourceOwner: orgID,
LastEmail: lastEmail,
VerifiedEmail: verifiedEmail,
PreferredLoginName: preferredLoginName,
LastPhone: lastPhone,
VerifiedPhone: verifiedPhone,
},
},
}, w
},
},
{
name: "send failed, retry",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
givenTemplate := "{{.LogoURL}}"
expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL)
w.message = &messages.Email{
Recipients: []string{lastEmail},
Subject: "Invitation to APP",
Content: expectContent,
}
w.sendError = sendError
codeAlg, code := cryptoValue(t, ctrl, "testcode")
expectTemplateQueries(queries, givenTemplate)
commands.EXPECT().NotificationRetryRequested(gomock.Any(), gomock.Any(), notificationID, instanceID,
&command.NotificationRetryRequest{
NotificationRequest: command.NotificationRequest{
UserID: userID,
UserResourceOwner: orgID,
AggregateID: "",
AggregateResourceOwner: "",
TriggerOrigin: eventOrigin,
EventType: user.HumanInviteCodeAddedType,
MessageType: domain.InviteUserMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
CodeExpiry: 1 * time.Hour,
Code: code,
UnverifiedNotificationChannel: true,
IsOTP: false,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
ApplicationName: "APP",
},
},
BackOff: 1 * time.Second,
NotifyUser: &query.NotifyUser{
ID: userID,
ResourceOwner: orgID,
LastEmail: lastEmail,
VerifiedEmail: verifiedEmail,
PreferredLoginName: preferredLoginName,
LastPhone: lastPhone,
VerifiedPhone: verifiedPhone,
},
},
sendError,
).Return(nil)
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: codeAlg,
now: testNow,
backOff: testBackOff,
maxAttempts: 3,
},
argsWorker{
event: &notification.RetryRequestedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{
InstanceID: instanceID,
AggregateID: notificationID,
ResourceOwner: sql.NullString{String: instanceID},
CreationDate: time.Now().Add(-2 * time.Second),
Typ: notification.RequestedType,
Seq: 2,
}),
Request: notification.Request{
UserID: userID,
UserResourceOwner: orgID,
AggregateID: "",
AggregateResourceOwner: "",
TriggeredAtOrigin: eventOrigin,
EventType: user.HumanInviteCodeAddedType,
MessageType: domain.InviteUserMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
CodeExpiry: 1 * time.Hour,
Code: code,
UnverifiedNotificationChannel: true,
IsOTP: false,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
ApplicationName: "APP",
},
},
BackOff: 1 * time.Second,
NotifyUser: &query.NotifyUser{
ID: userID,
ResourceOwner: orgID,
LastEmail: lastEmail,
VerifiedEmail: verifiedEmail,
PreferredLoginName: preferredLoginName,
LastPhone: lastPhone,
VerifiedPhone: verifiedPhone,
},
},
}, w
},
},
{
name: "send failed (max attempts), cancel",
test: func(ctrl *gomock.Controller, queries *mock.MockQueries, commands *mock.MockCommands) (f fieldsWorker, a argsWorker, w wantWorker) {
givenTemplate := "{{.LogoURL}}"
expectContent := fmt.Sprintf("%s%s/%s/%s", eventOrigin, assetsPath, policyID, logoURL)
w.message = &messages.Email{
Recipients: []string{lastEmail},
Subject: "Invitation to APP",
Content: expectContent,
}
w.sendError = sendError
codeAlg, code := cryptoValue(t, ctrl, "testcode")
expectTemplateQueries(queries, givenTemplate)
commands.EXPECT().NotificationCanceled(gomock.Any(), gomock.Any(), notificationID, instanceID, sendError).Return(nil)
return fieldsWorker{
queries: queries,
commands: commands,
es: eventstore.NewEventstore(&eventstore.Config{
Querier: es_repo_mock.NewRepo(t).MockQuerier,
}),
userDataCrypto: codeAlg,
now: testNow,
backOff: testBackOff,
maxAttempts: 2,
},
argsWorker{
event: &notification.RetryRequestedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(&repository.Event{
InstanceID: instanceID,
AggregateID: notificationID,
ResourceOwner: sql.NullString{String: instanceID},
CreationDate: time.Now().Add(-2 * time.Second),
Seq: 2,
Typ: notification.RequestedType,
}),
Request: notification.Request{
UserID: userID,
UserResourceOwner: orgID,
AggregateID: "",
AggregateResourceOwner: "",
TriggeredAtOrigin: eventOrigin,
EventType: user.HumanInviteCodeAddedType,
MessageType: domain.InviteUserMessageType,
NotificationType: domain.NotificationTypeEmail,
URLTemplate: fmt.Sprintf("%s/ui/login/user/invite?userID=%s&loginname={{.LoginName}}&code={{.Code}}&orgID=%s&authRequestID=%s", eventOrigin, userID, orgID, authRequestID),
CodeExpiry: 1 * time.Hour,
Code: code,
UnverifiedNotificationChannel: true,
IsOTP: false,
RequiresPreviousDomain: false,
Args: &domain.NotificationArguments{
ApplicationName: "APP",
},
},
BackOff: 1 * time.Second,
NotifyUser: &query.NotifyUser{
ID: userID,
ResourceOwner: orgID,
LastEmail: lastEmail,
VerifiedEmail: verifiedEmail,
PreferredLoginName: preferredLoginName,
LastPhone: lastPhone,
VerifiedPhone: verifiedPhone,
},
},
}, w
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
queries := mock.NewMockQueries(ctrl)
commands := mock.NewMockCommands(ctrl)
f, a, w := tt.test(ctrl, queries, commands)
err := newNotificationWorker(t, ctrl, queries, f, a, w).reduceNotificationRetry(
authz.WithInstanceID(context.Background(), instanceID),
&sql.Tx{},
a.event.(*notification.RetryRequestedEvent))
if w.err != nil {
w.err(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func newNotificationWorker(t *testing.T, ctrl *gomock.Controller, queries *mock.MockQueries, f fieldsWorker, a argsWorker, w wantWorker) *NotificationWorker {
queries.EXPECT().NotificationProviderByIDAndType(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().Return(&query.DebugNotificationProvider{}, nil)
smtpAlg, _ := cryptoValue(t, ctrl, "smtppw")
channel := channel_mock.NewMockNotificationChannel(ctrl)
if w.err == nil {
if w.message != nil {
w.message.TriggeringEvent = a.event
channel.EXPECT().HandleMessage(w.message).Return(w.sendError)
}
if w.messageSMS != nil {
w.messageSMS.TriggeringEvent = a.event
channel.EXPECT().HandleMessage(w.messageSMS).DoAndReturn(func(message *messages.SMS) error {
message.VerificationID = gu.Ptr(verificationID)
return w.sendError
})
}
}
return &NotificationWorker{
commands: f.commands,
queries: NewNotificationQueries(
f.queries,
f.es,
externalDomain,
externalPort,
externalSecure,
"",
f.userDataCrypto,
smtpAlg,
f.SMSTokenCrypto,
),
channels: &notificationChannels{
Chain: *senders.ChainChannels(channel),
EmailConfig: &email.Config{
ProviderConfig: &email.Provider{
ID: "emailProviderID",
Description: "description",
},
SMTPConfig: &smtp.Config{
SMTP: smtp.SMTP{
Host: "host",
User: "user",
Password: "password",
},
Tls: true,
From: "from",
FromName: "fromName",
ReplyToAddress: "replyToAddress",
},
WebhookConfig: nil,
},
SMSConfig: &sms.Config{
ProviderConfig: &sms.Provider{
ID: "smsProviderID",
Description: "description",
},
TwilioConfig: &twilio.Config{
SID: "sid",
Token: "token",
SenderNumber: "senderNumber",
VerifyServiceSID: "verifyServiceSID",
},
},
},
config: WorkerConfig{
Workers: 1,
BulkLimit: 10,
RequeueEvery: 2 * time.Second,
HandleActiveInstances: 0,
TransactionDuration: 5 * time.Second,
MaxAttempts: f.maxAttempts,
MaxTtl: 5 * time.Minute,
MinRetryDelay: 1 * time.Second,
MaxRetryDelay: 10 * time.Second,
RetryDelayFactor: 2,
},
now: f.now,
backOff: f.backOff,
}
}
func TestNotificationWorker_exponentialBackOff(t *testing.T) {
type fields struct {
config WorkerConfig
}
type args struct {
current time.Duration
}
tests := []struct {
name string
fields fields
args args
wantMin time.Duration
wantMax time.Duration
}{
{
name: "less than min, min - 1.5*min",
fields: fields{
config: WorkerConfig{
MinRetryDelay: 1 * time.Second,
MaxRetryDelay: 5 * time.Second,
RetryDelayFactor: 1.5,
},
},
args: args{
current: 0,
},
wantMin: 1000 * time.Millisecond,
wantMax: 1500 * time.Millisecond,
},
{
name: "current, 1.5*current - max",
fields: fields{
config: WorkerConfig{
MinRetryDelay: 1 * time.Second,
MaxRetryDelay: 5 * time.Second,
RetryDelayFactor: 1.5,
},
},
args: args{
current: 4 * time.Second,
},
wantMin: 4000 * time.Millisecond,
wantMax: 5000 * time.Millisecond,
},
{
name: "max, max",
fields: fields{
config: WorkerConfig{
MinRetryDelay: 1 * time.Second,
MaxRetryDelay: 5 * time.Second,
RetryDelayFactor: 1.5,
},
},
args: args{
current: 5 * time.Second,
},
wantMin: 5000 * time.Millisecond,
wantMax: 5000 * time.Millisecond,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := &NotificationWorker{
config: tt.fields.config,
}
b := w.exponentialBackOff(tt.args.current)
assert.GreaterOrEqual(t, b, tt.wantMin)
assert.LessOrEqual(t, b, tt.wantMax)
})
}
}

View File

@@ -2,23 +2,84 @@ package handlers
import (
"context"
"strings"
"time"
http_util "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/console"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/notification/senders"
"github.com/zitadel/zitadel/internal/notification/types"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/repository/session"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/zerrors"
)
func init() {
RegisterSentHandler(user.HumanInitialCodeAddedType,
func(ctx context.Context, commands Commands, id, orgID string, _ *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.HumanInitCodeSent(ctx, orgID, id)
},
)
RegisterSentHandler(user.HumanEmailCodeAddedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.HumanEmailVerificationCodeSent(ctx, orgID, id)
},
)
RegisterSentHandler(user.HumanPasswordCodeAddedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.PasswordCodeSent(ctx, orgID, id, generatorInfo)
},
)
RegisterSentHandler(user.HumanOTPSMSCodeAddedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.HumanOTPSMSCodeSent(ctx, id, orgID, generatorInfo)
},
)
RegisterSentHandler(session.OTPSMSChallengedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.OTPSMSSent(ctx, id, orgID, generatorInfo)
},
)
RegisterSentHandler(user.HumanOTPEmailCodeAddedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.HumanOTPEmailCodeSent(ctx, id, orgID)
},
)
RegisterSentHandler(session.OTPEmailChallengedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.OTPEmailSent(ctx, id, orgID)
},
)
RegisterSentHandler(user.UserDomainClaimedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.UserDomainClaimedSent(ctx, orgID, id)
},
)
RegisterSentHandler(user.HumanPasswordlessInitCodeRequestedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.HumanPasswordlessInitCodeSent(ctx, id, orgID, args["CodeID"].(string))
},
)
RegisterSentHandler(user.HumanPasswordChangedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.PasswordChangeSent(ctx, orgID, id)
},
)
RegisterSentHandler(user.HumanPhoneCodeAddedType,
func(ctx context.Context, commands Commands, id, orgID string, generatorInfo *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.HumanPhoneVerificationCodeSent(ctx, orgID, id, generatorInfo)
},
)
RegisterSentHandler(user.HumanInviteCodeAddedType,
func(ctx context.Context, commands Commands, id, orgID string, _ *senders.CodeGeneratorInfo, args map[string]any) error {
return commands.InviteCodeSent(ctx, orgID, id)
},
)
}
const (
UserNotificationsProjectionTable = "projections.notifications"
)
@@ -26,7 +87,6 @@ const (
type userNotifier struct {
commands Commands
queries *NotificationQueries
channels types.ChannelChains
otpEmailTmpl string
}
@@ -35,14 +95,12 @@ func NewUserNotifier(
config handler.Config,
commands Commands,
queries *NotificationQueries,
channels types.ChannelChains,
otpEmailTmpl string,
) *handler.Handler {
return handler.NewHandler(ctx, &config, &userNotifier{
commands: commands,
queries: queries,
otpEmailTmpl: otpEmailTmpl,
channels: channels,
})
}
@@ -146,39 +204,29 @@ func (u *userNotifier) reduceInitCodeAdded(event eventstore.Event) (*handler.Sta
if alreadyHandled {
return nil
}
code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
if err != nil {
return err
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.InitCodeMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e).
SendUserInitCode(ctx, notifyUser, code, e.AuthRequestID)
if err != nil {
return err
}
return u.commands.HumanInitCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID)
origin := http_util.DomainContext(ctx).Origin()
return u.commands.RequestNotification(
ctx,
e.Aggregate().ResourceOwner,
command.NewNotificationRequest(
e.Aggregate().ID,
e.Aggregate().ResourceOwner,
origin,
e.EventType,
domain.NotificationTypeEmail,
domain.InitCodeMessageType,
).
WithURLTemplate(login.InitUserLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID)).
WithCode(e.Code, e.Expiry).
WithArgs(&domain.NotificationArguments{
AuthRequestID: e.AuthRequestID,
}).
WithUnverifiedChannel(),
)
}), nil
}
@@ -203,42 +251,39 @@ func (u *userNotifier) reduceEmailCodeAdded(event eventstore.Event) (*handler.St
if alreadyHandled {
return nil
}
code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
if err != nil {
return err
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifyEmailMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e).
SendEmailVerificationCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID)
if err != nil {
return err
}
return u.commands.HumanEmailVerificationCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID)
origin := http_util.DomainContext(ctx).Origin()
return u.commands.RequestNotification(ctx,
e.Aggregate().ResourceOwner,
command.NewNotificationRequest(
e.Aggregate().ID,
e.Aggregate().ResourceOwner,
origin,
e.EventType,
domain.NotificationTypeEmail,
domain.VerifyEmailMessageType,
).
WithURLTemplate(u.emailCodeTemplate(origin, e)).
WithCode(e.Code, e.Expiry).
WithArgs(&domain.NotificationArguments{
AuthRequestID: e.AuthRequestID,
}).
WithUnverifiedChannel(),
)
}), nil
}
func (u *userNotifier) emailCodeTemplate(origin string, e *user.HumanEmailCodeAddedEvent) string {
if e.URLTemplate != "" {
return e.URLTemplate
}
return login.MailVerificationLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID)
}
func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanPasswordCodeAddedEvent)
if !ok {
@@ -259,64 +304,74 @@ func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler
if alreadyHandled {
return nil
}
var code string
if e.Code != nil {
code, err = crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
if err != nil {
return err
}
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.PasswordResetMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
generatorInfo := new(senders.CodeGeneratorInfo)
notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e)
if e.NotificationType == domain.NotificationTypeSms {
notify = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e, generatorInfo)
}
err = notify.SendPasswordCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID)
if err != nil {
return err
}
return u.commands.PasswordCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID, generatorInfo)
origin := http_util.DomainContext(ctx).Origin()
return u.commands.RequestNotification(ctx,
e.Aggregate().ResourceOwner,
command.NewNotificationRequest(
e.Aggregate().ID,
e.Aggregate().ResourceOwner,
origin,
e.EventType,
e.NotificationType,
domain.PasswordResetMessageType,
).
WithURLTemplate(u.passwordCodeTemplate(origin, e)).
WithCode(e.Code, e.Expiry).
WithArgs(&domain.NotificationArguments{
AuthRequestID: e.AuthRequestID,
}).
WithUnverifiedChannel(),
)
}), nil
}
func (u *userNotifier) passwordCodeTemplate(origin string, e *user.HumanPasswordCodeAddedEvent) string {
if e.URLTemplate != "" {
return e.URLTemplate
}
return login.InitPasswordLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID)
}
func (u *userNotifier) reduceOTPSMSCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanOTPSMSCodeAddedEvent)
if !ok {
return nil, zerrors.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,
)
return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error {
ctx := HandlerContext(event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.HumanOTPSMSCodeAddedType,
user.HumanOTPSMSCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
return u.commands.RequestNotification(ctx,
e.Aggregate().ResourceOwner,
command.NewNotificationRequest(
e.Aggregate().ID,
e.Aggregate().ResourceOwner,
http_util.DomainContext(ctx).Origin(),
e.EventType,
domain.NotificationTypeSms,
domain.VerifySMSOTPMessageType,
).
WithCode(e.Code, e.Expiry).
WithArgs(otpArgs(ctx, e.Expiry)).
WithOTP(),
)
}), nil
}
func (u *userNotifier) reduceSessionOTPSMSChallenged(event eventstore.Event) (*handler.Statement, error) {
@@ -327,75 +382,46 @@ func (u *userNotifier) reduceSessionOTPSMSChallenged(event eventstore.Event) (*h
if e.CodeReturned {
return handler.NewNoOpStatement(e), nil
}
ctx := HandlerContext(event.Aggregate())
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, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) (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 handler.NewNoOpStatement(event), nil
}
var plainCode string
if code != nil {
plainCode, err = crypto.DecryptString(code, u.queries.UserDataCrypto)
return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error {
ctx := HandlerContext(event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
session.OTPSMSChallengedType,
session.OTPSMSSentType)
if err != nil {
return nil, err
return err
}
if alreadyHandled {
return nil
}
s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "")
if err != nil {
return err
}
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, resourceOwner, false)
if err != nil {
return nil, err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, userID)
if err != nil {
return nil, err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifySMSOTPMessageType)
if err != nil {
return nil, err
}
ctx, err = u.queries.Origin(ctx, event)
if err != nil {
return nil, err
}
generatorInfo := new(senders.CodeGeneratorInfo)
notify := types.SendSMS(ctx, u.channels, translator, notifyUser, colors, event, generatorInfo)
err = notify.SendOTPSMSCode(ctx, plainCode, expiry)
if err != nil {
return nil, err
}
err = sentCommand(ctx, event.Aggregate().ID, event.Aggregate().ResourceOwner, generatorInfo)
if err != nil {
return nil, err
}
return handler.NewNoOpStatement(event), nil
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
args := otpArgs(ctx, e.Expiry)
args.SessionID = e.Aggregate().ID
return u.commands.RequestNotification(ctx,
s.UserFactor.ResourceOwner,
command.NewNotificationRequest(
s.UserFactor.UserID,
s.UserFactor.ResourceOwner,
http_util.DomainContext(ctx).Origin(),
e.EventType,
domain.NotificationTypeSms,
domain.VerifySMSOTPMessageType,
).
WithAggregate(e.Aggregate().ID, e.Aggregate().ResourceOwner).
WithCode(e.Code, e.Expiry).
WithOTP().
WithArgs(args),
)
}), nil
}
func (u *userNotifier) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) {
@@ -403,24 +429,46 @@ func (u *userNotifier) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler
if !ok {
return nil, zerrors.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,
)
return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error {
ctx := HandlerContext(event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
user.HumanOTPEmailCodeAddedType,
user.HumanOTPEmailCodeSentType)
if err != nil {
return err
}
if alreadyHandled {
return nil
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
origin := http_util.DomainContext(ctx).Origin()
var authRequestID string
if e.AuthRequestInfo != nil {
authRequestID = e.AuthRequestInfo.ID
}
args := otpArgs(ctx, e.Expiry)
args.AuthRequestID = authRequestID
return u.commands.RequestNotification(ctx,
e.Aggregate().ResourceOwner,
command.NewNotificationRequest(
e.Aggregate().ID,
e.Aggregate().ResourceOwner,
origin,
e.EventType,
domain.NotificationTypeEmail,
domain.VerifyEmailOTPMessageType,
).
WithURLTemplate(login.OTPLinkTemplate(origin, authRequestID, domain.MFATypeOTPEmail)).
WithCode(e.Code, e.Expiry).
WithOTP().
WithArgs(args),
)
}), nil
}
func (u *userNotifier) reduceSessionOTPEmailChallenged(event eventstore.Event) (*handler.Statement, error) {
@@ -431,93 +479,63 @@ func (u *userNotifier) reduceSessionOTPEmailChallenged(event eventstore.Event) (
if e.ReturnCode {
return handler.NewNoOpStatement(e), nil
}
ctx := HandlerContext(event.Aggregate())
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
return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error {
ctx := HandlerContext(event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
session.OTPEmailChallengedType,
session.OTPEmailSentType)
if err != nil {
return err
}
if err := domain.RenderOTPEmailURLTemplate(&buf, urlTmpl, code, user.ID, user.PreferredLoginName, user.DisplayName, e.Aggregate().ID, user.PreferredLanguage); err != nil {
return "", err
if alreadyHandled {
return nil
}
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,
)
s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "")
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
origin := http_util.DomainContext(ctx).Origin()
args := otpArgs(ctx, e.Expiry)
args.SessionID = e.Aggregate().ID
return u.commands.RequestNotification(ctx,
s.UserFactor.ResourceOwner,
command.NewNotificationRequest(
s.UserFactor.UserID,
s.UserFactor.ResourceOwner,
origin,
e.EventType,
domain.NotificationTypeEmail,
domain.VerifyEmailOTPMessageType,
).
WithAggregate(e.Aggregate().ID, e.Aggregate().ResourceOwner).
WithURLTemplate(u.otpEmailTemplate(origin, e)).
WithCode(e.Code, e.Expiry).
WithOTP().
WithArgs(args),
)
}), nil
}
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 handler.NewNoOpStatement(event), nil
}
plainCode, err := crypto.DecryptString(code, u.queries.UserDataCrypto)
if err != nil {
return nil, err
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, resourceOwner, false)
if err != nil {
return nil, err
func (u *userNotifier) otpEmailTemplate(origin string, e *session.OTPEmailChallengedEvent) string {
if e.URLTmpl != "" {
return e.URLTmpl
}
return origin + u.otpEmailTmpl
}
template, err := u.queries.MailTemplateByOrg(ctx, resourceOwner, false)
if err != nil {
return nil, err
func otpArgs(ctx context.Context, expiry time.Duration) *domain.NotificationArguments {
domainCtx := http_util.DomainContext(ctx)
return &domain.NotificationArguments{
Origin: domainCtx.Origin(),
Domain: domainCtx.RequestedDomain(),
Expiry: expiry,
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, userID)
if err != nil {
return nil, err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, resourceOwner, domain.VerifyEmailOTPMessageType)
if err != nil {
return nil, err
}
ctx, err = u.queries.Origin(ctx, event)
if err != nil {
return nil, err
}
url, err := urlTmpl(plainCode, http_util.DomainContext(ctx).Origin(), notifyUser)
if err != nil {
return nil, err
}
notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, event)
err = notify.SendOTPEmailCode(ctx, url, plainCode, expiry)
if err != nil {
return nil, err
}
err = sentCommand(ctx, event.Aggregate().ID, event.Aggregate().ResourceOwner)
if err != nil {
return nil, err
}
return handler.NewNoOpStatement(event), nil
}
func (u *userNotifier) reduceDomainClaimed(event eventstore.Event) (*handler.Statement, error) {
@@ -535,35 +553,28 @@ func (u *userNotifier) reduceDomainClaimed(event eventstore.Event) (*handler.Sta
if alreadyHandled {
return nil
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.DomainClaimedMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e).
SendDomainClaimed(ctx, notifyUser, e.UserName)
if err != nil {
return err
}
return u.commands.UserDomainClaimedSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID)
origin := http_util.DomainContext(ctx).Origin()
return u.commands.RequestNotification(ctx,
e.Aggregate().ResourceOwner,
command.NewNotificationRequest(
e.Aggregate().ID,
e.Aggregate().ResourceOwner,
origin,
e.EventType,
domain.NotificationTypeEmail,
domain.DomainClaimedMessageType,
).
WithURLTemplate(login.LoginLink(origin, e.Aggregate().ResourceOwner)).
WithUnverifiedChannel().
WithPreviousDomain().
WithArgs(&domain.NotificationArguments{
TempUsername: e.UserName,
}),
)
}), nil
}
@@ -585,42 +596,37 @@ func (u *userNotifier) reducePasswordlessCodeRequested(event eventstore.Event) (
if alreadyHandled {
return nil
}
code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
if err != nil {
return err
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.PasswordlessRegistrationMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e).
SendPasswordlessRegistrationLink(ctx, notifyUser, code, e.ID, e.URLTemplate)
if err != nil {
return err
}
return u.commands.HumanPasswordlessInitCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.ID)
origin := http_util.DomainContext(ctx).Origin()
return u.commands.RequestNotification(ctx,
e.Aggregate().ResourceOwner,
command.NewNotificationRequest(
e.Aggregate().ID,
e.Aggregate().ResourceOwner,
origin,
e.EventType,
domain.NotificationTypeEmail,
domain.PasswordlessRegistrationMessageType,
).
WithURLTemplate(u.passwordlessCodeTemplate(origin, e)).
WithCode(e.Code, e.Expiry).
WithArgs(&domain.NotificationArguments{
CodeID: e.ID,
}),
)
}), nil
}
func (u *userNotifier) passwordlessCodeTemplate(origin string, e *user.HumanPasswordlessInitCodeRequestedEvent) string {
if e.URLTemplate != "" {
return e.URLTemplate
}
return domain.PasswordlessInitCodeLinkTemplate(origin+login.HandlerPrefix+login.EndpointPasswordlessRegistration, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.ID)
}
func (u *userNotifier) reducePasswordChanged(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanPasswordChangedEvent)
if !ok {
@@ -638,10 +644,7 @@ func (u *userNotifier) reducePasswordChanged(event eventstore.Event) (*handler.S
}
notificationPolicy, err := u.queries.NotificationPolicyByOrg(ctx, true, e.Aggregate().ResourceOwner, false)
if zerrors.IsNotFound(err) {
return nil
}
if err != nil {
if err != nil && !zerrors.IsNotFound(err) {
return err
}
@@ -649,34 +652,25 @@ func (u *userNotifier) reducePasswordChanged(event eventstore.Event) (*handler.S
return nil
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.PasswordChangeMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e).
SendPasswordChange(ctx, notifyUser)
if err != nil {
return err
}
return u.commands.PasswordChangeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID)
origin := http_util.DomainContext(ctx).Origin()
return u.commands.RequestNotification(ctx,
e.Aggregate().ResourceOwner,
command.NewNotificationRequest(
e.Aggregate().ID,
e.Aggregate().ResourceOwner,
origin,
e.EventType,
domain.NotificationTypeEmail,
domain.PasswordChangeMessageType,
).
WithURLTemplate(console.LoginHintLink(origin, "{{.PreferredLoginName}}")).
WithUnverifiedChannel(),
)
}), nil
}
@@ -700,37 +694,28 @@ func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.St
if alreadyHandled {
return nil
}
var code string
if e.Code != nil {
code, err = crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
if err != nil {
return err
}
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifyPhoneMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
generatorInfo := new(senders.CodeGeneratorInfo)
if err = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e, generatorInfo).
SendPhoneVerificationCode(ctx, code); err != nil {
return err
}
return u.commands.HumanPhoneVerificationCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID, generatorInfo)
return u.commands.RequestNotification(ctx,
e.Aggregate().ResourceOwner,
command.NewNotificationRequest(
e.Aggregate().ID,
e.Aggregate().ResourceOwner,
http_util.DomainContext(ctx).Origin(),
e.EventType,
domain.NotificationTypeSms,
domain.VerifyPhoneMessageType,
).
WithCode(e.Code, e.Expiry).
WithUnverifiedChannel().
WithArgs(&domain.NotificationArguments{
Domain: http_util.DomainContext(ctx).RequestedDomain(),
}),
)
}), nil
}
@@ -753,42 +738,45 @@ func (u *userNotifier) reduceInviteCodeAdded(event eventstore.Event) (*handler.S
if alreadyHandled {
return nil
}
code, err := crypto.DecryptString(e.Code, u.queries.UserDataCrypto)
if err != nil {
return err
}
colors, err := u.queries.ActiveLabelPolicyByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
template, err := u.queries.MailTemplateByOrg(ctx, e.Aggregate().ResourceOwner, false)
if err != nil {
return err
}
notifyUser, err := u.queries.GetNotifyUserByID(ctx, true, e.Aggregate().ID)
if err != nil {
return err
}
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.InviteUserMessageType)
if err != nil {
return err
}
ctx, err = u.queries.Origin(ctx, e)
if err != nil {
return err
}
notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e)
err = notify.SendInviteCode(ctx, notifyUser, code, e.ApplicationName, e.URLTemplate, e.AuthRequestID)
if err != nil {
return err
origin := http_util.DomainContext(ctx).Origin()
applicationName := e.ApplicationName
if applicationName == "" {
applicationName = "ZITADEL"
}
return u.commands.InviteCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner)
return u.commands.RequestNotification(ctx,
e.Aggregate().ResourceOwner,
command.NewNotificationRequest(
e.Aggregate().ID,
e.Aggregate().ResourceOwner,
origin,
e.EventType,
domain.NotificationTypeEmail,
domain.InviteUserMessageType,
).
WithURLTemplate(u.inviteCodeTemplate(origin, e)).
WithCode(e.Code, e.Expiry).
WithUnverifiedChannel().
WithArgs(&domain.NotificationArguments{
AuthRequestID: e.AuthRequestID,
ApplicationName: applicationName,
}),
)
}), nil
}
func (u *userNotifier) inviteCodeTemplate(origin string, e *user.HumanInviteCodeAddedEvent) string {
if e.URLTemplate != "" {
return e.URLTemplate
}
return login.InviteUserLinkTemplate(origin, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.AuthRequestID)
}
func (u *userNotifier) checkIfCodeAlreadyHandledOrExpired(ctx context.Context, event eventstore.Event, expiry time.Duration, data map[string]interface{}, eventTypes ...eventstore.EventType) (bool, error) {
if expiry > 0 && event.CreatedAt().Add(expiry).Before(time.Now().UTC()) {
return true, nil

File diff suppressed because it is too large Load Diff