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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 4005 additions and 2158 deletions

View File

@ -77,7 +77,7 @@ jobs:
go_version: "1.22" go_version: "1.22"
node_version: "18" node_version: "18"
buf_version: "latest" buf_version: "latest"
go_lint_version: "v1.55.2" go_lint_version: "v1.62.2"
core_cache_key: ${{ needs.core.outputs.cache_key }} core_cache_key: ${{ needs.core.outputs.cache_key }}
core_cache_path: ${{ needs.core.outputs.cache_path }} core_cache_path: ${{ needs.core.outputs.cache_path }}

View File

@ -448,6 +448,40 @@ Projections:
# Telemetry data synchronization is not time critical. Setting RequeueEvery to 55 minutes doesn't annoy the database too much. # Telemetry data synchronization is not time critical. Setting RequeueEvery to 55 minutes doesn't annoy the database too much.
RequeueEvery: 3300s # ZITADEL_PROJECTIONS_CUSTOMIZATIONS_TELEMETRY_REQUEUEEVERY RequeueEvery: 3300s # ZITADEL_PROJECTIONS_CUSTOMIZATIONS_TELEMETRY_REQUEUEEVERY
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
Auth: Auth:
# See Projections.BulkLimit # See Projections.BulkLimit
SearchLimit: 1000 # ZITADEL_AUTH_SEARCHLIMIT SearchLimit: 1000 # ZITADEL_AUTH_SEARCHLIMIT

View File

@ -69,6 +69,7 @@ func projectionsCmd() *cobra.Command {
type ProjectionsConfig struct { type ProjectionsConfig struct {
Destination database.Config Destination database.Config
Projections projection.Config Projections projection.Config
Notifications handlers.WorkerConfig
EncryptionKeys *encryption.EncryptionKeyConfig EncryptionKeys *encryption.EncryptionKeyConfig
SystemAPIUsers map[string]*internal_authz.SystemAPIUser SystemAPIUsers map[string]*internal_authz.SystemAPIUser
Eventstore *eventstore.Config Eventstore *eventstore.Config
@ -205,6 +206,7 @@ func projections(
config.Projections.Customizations["notificationsquotas"], config.Projections.Customizations["notificationsquotas"],
config.Projections.Customizations["backchannel"], config.Projections.Customizations["backchannel"],
config.Projections.Customizations["telemetry"], config.Projections.Customizations["telemetry"],
config.Notifications,
*config.Telemetry, *config.Telemetry,
config.ExternalDomain, config.ExternalDomain,
config.ExternalPort, config.ExternalPort,
@ -219,6 +221,7 @@ func projections(
keys.SMS, keys.SMS,
keys.OIDC, keys.OIDC,
config.OIDC.DefaultBackChannelLogoutLifetime, config.OIDC.DefaultBackChannelLogoutLifetime,
client,
) )
config.Auth.Spooler.Client = client config.Auth.Spooler.Client = client

View File

@ -42,6 +42,7 @@ type Config struct {
DefaultInstance command.InstanceSetup DefaultInstance command.InstanceSetup
Machine *id.Config Machine *id.Config
Projections projection.Config Projections projection.Config
Notifications handlers.WorkerConfig
Eventstore *eventstore.Config Eventstore *eventstore.Config
InitProjections InitProjections InitProjections InitProjections

View File

@ -437,6 +437,7 @@ func initProjections(
config.Projections.Customizations["notificationsquotas"], config.Projections.Customizations["notificationsquotas"],
config.Projections.Customizations["backchannel"], config.Projections.Customizations["backchannel"],
config.Projections.Customizations["telemetry"], config.Projections.Customizations["telemetry"],
config.Notifications,
*config.Telemetry, *config.Telemetry,
config.ExternalDomain, config.ExternalDomain,
config.ExternalPort, config.ExternalPort,
@ -451,6 +452,7 @@ func initProjections(
keys.SMS, keys.SMS,
keys.OIDC, keys.OIDC,
config.OIDC.DefaultBackChannelLogoutLifetime, config.OIDC.DefaultBackChannelLogoutLifetime,
queryDBClient,
) )
for _, p := range notify_handler.Projections() { for _, p := range notify_handler.Projections() {
err := migration.Migrate(ctx, eventstoreClient, p) err := migration.Migrate(ctx, eventstoreClient, p)

View File

@ -54,6 +54,7 @@ type Config struct {
Metrics metrics.Config Metrics metrics.Config
Profiler profiler.Config Profiler profiler.Config
Projections projection.Config Projections projection.Config
Notifications handlers.WorkerConfig
Auth auth_es.Config Auth auth_es.Config
Admin admin_es.Config Admin admin_es.Config
UserAgentCookie *middleware.UserAgentCookieConfig UserAgentCookie *middleware.UserAgentCookieConfig

View File

@ -277,6 +277,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
config.Projections.Customizations["notificationsquotas"], config.Projections.Customizations["notificationsquotas"],
config.Projections.Customizations["backchannel"], config.Projections.Customizations["backchannel"],
config.Projections.Customizations["telemetry"], config.Projections.Customizations["telemetry"],
config.Notifications,
*config.Telemetry, *config.Telemetry,
config.ExternalDomain, config.ExternalDomain,
config.ExternalPort, config.ExternalPort,
@ -291,6 +292,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
keys.SMS, keys.SMS,
keys.OIDC, keys.OIDC,
config.OIDC.DefaultBackChannelLogoutLifetime, config.OIDC.DefaultBackChannelLogoutLifetime,
queryDBClient,
) )
notification.Start(ctx) notification.Start(ctx)

View File

@ -1,8 +1,8 @@
package login package login
import ( import (
"fmt"
"net/http" "net/http"
"net/url"
http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
@ -38,13 +38,13 @@ type initPasswordData struct {
HasSymbol string HasSymbol string
} }
func InitPasswordLink(origin, userID, code, orgID, authRequestID string) string { func InitPasswordLinkTemplate(origin, userID, orgID, authRequestID string) string {
v := url.Values{} return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%s&%s=%s",
v.Set(queryInitPWUserID, userID) externalLink(origin), EndpointInitPassword,
v.Set(queryInitPWCode, code) queryInitPWUserID, userID,
v.Set(queryOrgID, orgID) queryInitPWCode, "{{.Code}}",
v.Set(QueryAuthRequestID, authRequestID) queryOrgID, orgID,
return externalLink(origin) + EndpointInitPassword + "?" + v.Encode() QueryAuthRequestID, authRequestID)
} }
func (l *Login) handleInitPassword(w http.ResponseWriter, r *http.Request) { func (l *Login) handleInitPassword(w http.ResponseWriter, r *http.Request) {

View File

@ -1,8 +1,8 @@
package login package login
import ( import (
"fmt"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware"
@ -44,15 +44,15 @@ type initUserData struct {
HasSymbol string HasSymbol string
} }
func InitUserLink(origin, userID, loginName, code, orgID string, passwordSet bool, authRequestID string) string { func InitUserLinkTemplate(origin, userID, orgID, authRequestID string) string {
v := url.Values{} return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%s&%s=%s&%s=%s&%s=%s",
v.Set(queryInitUserUserID, userID) externalLink(origin), EndpointInitUser,
v.Set(queryInitUserLoginName, loginName) queryInitUserUserID, userID,
v.Set(queryInitUserCode, code) queryInitUserLoginName, "{{.LoginName}}",
v.Set(queryOrgID, orgID) queryInitUserCode, "{{.Code}}",
v.Set(queryInitUserPassword, strconv.FormatBool(passwordSet)) queryOrgID, orgID,
v.Set(QueryAuthRequestID, authRequestID) queryInitUserPassword, "{{.PasswordSet}}",
return externalLink(origin) + EndpointInitUser + "?" + v.Encode() QueryAuthRequestID, authRequestID)
} }
func (l *Login) handleInitUser(w http.ResponseWriter, r *http.Request) { func (l *Login) handleInitUser(w http.ResponseWriter, r *http.Request) {

View File

@ -1,8 +1,8 @@
package login package login
import ( import (
"fmt"
"net/http" "net/http"
"net/url"
http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
@ -40,14 +40,14 @@ type inviteUserData struct {
HasSymbol string HasSymbol string
} }
func InviteUserLink(origin, userID, loginName, code, orgID string, authRequestID string) string { func InviteUserLinkTemplate(origin, userID, orgID string, authRequestID string) string {
v := url.Values{} return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%s&%s=%s&%s=%s",
v.Set(queryInviteUserUserID, userID) externalLink(origin), EndpointInviteUser,
v.Set(queryInviteUserLoginName, loginName) queryInviteUserUserID, userID,
v.Set(queryInviteUserCode, code) queryInviteUserLoginName, "{{.LoginName}}",
v.Set(queryOrgID, orgID) queryInviteUserCode, "{{.Code}}",
v.Set(QueryAuthRequestID, authRequestID) queryOrgID, orgID,
return externalLink(origin) + EndpointInviteUser + "?" + v.Encode() QueryAuthRequestID, authRequestID)
} }
func (l *Login) handleInviteUser(w http.ResponseWriter, r *http.Request) { func (l *Login) handleInviteUser(w http.ResponseWriter, r *http.Request) {

View File

@ -2,8 +2,8 @@ package login
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"net/url"
"slices" "slices"
"github.com/zitadel/logging" "github.com/zitadel/logging"
@ -43,13 +43,13 @@ type mailVerificationData struct {
HasSymbol string HasSymbol string
} }
func MailVerificationLink(origin, userID, code, orgID, authRequestID string) string { func MailVerificationLinkTemplate(origin, userID, orgID, authRequestID string) string {
v := url.Values{} return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%s&%s=%s",
v.Set(queryUserID, userID) externalLink(origin), EndpointMailVerification,
v.Set(queryCode, code) queryUserID, userID,
v.Set(queryOrgID, orgID) queryCode, "{{.Code}}",
v.Set(QueryAuthRequestID, authRequestID) queryOrgID, orgID,
return externalLink(origin) + EndpointMailVerification + "?" + v.Encode() QueryAuthRequestID, authRequestID)
} }
func (l *Login) handleMailVerification(w http.ResponseWriter, r *http.Request) { func (l *Login) handleMailVerification(w http.ResponseWriter, r *http.Request) {

View File

@ -27,8 +27,8 @@ type mfaOTPFormData struct {
Provider domain.MFAType `schema:"provider"` Provider domain.MFAType `schema:"provider"`
} }
func OTPLink(origin, authRequestID, code string, provider domain.MFAType) string { func OTPLinkTemplate(origin, authRequestID string, provider domain.MFAType) string {
return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%d", externalLink(origin), EndpointMFAOTPVerify, QueryAuthRequestID, authRequestID, queryCode, code, querySelectedProvider, provider) return fmt.Sprintf("%s%s?%s=%s&%s=%s&%s=%d", externalLink(origin), EndpointMFAOTPVerify, QueryAuthRequestID, authRequestID, queryCode, "{{.Code}}", querySelectedProvider, provider)
} }
// renderOTPVerification renders the OTP verification for SMS and Email based on the passed MFAType. // renderOTPVerification renders the OTP verification for SMS and Email based on the passed MFAType.

View File

@ -0,0 +1,162 @@
package command
import (
"context"
"database/sql"
"time"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/repository/notification"
)
type NotificationRequest struct {
UserID string
UserResourceOwner string
TriggerOrigin string
URLTemplate string
Code *crypto.CryptoValue
CodeExpiry time.Duration
EventType eventstore.EventType
NotificationType domain.NotificationType
MessageType string
UnverifiedNotificationChannel bool
Args *domain.NotificationArguments
AggregateID string
AggregateResourceOwner string
IsOTP bool
RequiresPreviousDomain bool
}
type NotificationRetryRequest struct {
NotificationRequest
BackOff time.Duration
NotifyUser *query.NotifyUser
}
func NewNotificationRequest(
userID, resourceOwner, triggerOrigin string,
eventType eventstore.EventType,
notificationType domain.NotificationType,
messageType string,
) *NotificationRequest {
return &NotificationRequest{
UserID: userID,
UserResourceOwner: resourceOwner,
TriggerOrigin: triggerOrigin,
EventType: eventType,
NotificationType: notificationType,
MessageType: messageType,
}
}
func (r *NotificationRequest) WithCode(code *crypto.CryptoValue, expiry time.Duration) *NotificationRequest {
r.Code = code
r.CodeExpiry = expiry
return r
}
func (r *NotificationRequest) WithURLTemplate(urlTemplate string) *NotificationRequest {
r.URLTemplate = urlTemplate
return r
}
func (r *NotificationRequest) WithUnverifiedChannel() *NotificationRequest {
r.UnverifiedNotificationChannel = true
return r
}
func (r *NotificationRequest) WithArgs(args *domain.NotificationArguments) *NotificationRequest {
r.Args = args
return r
}
func (r *NotificationRequest) WithAggregate(id, resourceOwner string) *NotificationRequest {
r.AggregateID = id
r.AggregateResourceOwner = resourceOwner
return r
}
func (r *NotificationRequest) WithOTP() *NotificationRequest {
r.IsOTP = true
return r
}
func (r *NotificationRequest) WithPreviousDomain() *NotificationRequest {
r.RequiresPreviousDomain = true
return r
}
// RequestNotification writes a new notification.RequestEvent with the notification.Aggregate to the eventstore
func (c *Commands) RequestNotification(
ctx context.Context,
resourceOwner string,
request *NotificationRequest,
) error {
id, err := c.idGenerator.Next()
if err != nil {
return err
}
_, err = c.eventstore.Push(ctx, notification.NewRequestedEvent(ctx, &notification.NewAggregate(id, resourceOwner).Aggregate,
request.UserID,
request.UserResourceOwner,
request.AggregateID,
request.AggregateResourceOwner,
request.TriggerOrigin,
request.URLTemplate,
request.Code,
request.CodeExpiry,
request.EventType,
request.NotificationType,
request.MessageType,
request.UnverifiedNotificationChannel,
request.IsOTP,
request.RequiresPreviousDomain,
request.Args))
return err
}
// NotificationCanceled writes a new notification.CanceledEvent with the notification.Aggregate to the eventstore
func (c *Commands) NotificationCanceled(ctx context.Context, tx *sql.Tx, id, resourceOwner string, requestError error) error {
var errorMessage string
if requestError != nil {
errorMessage = requestError.Error()
}
_, err := c.eventstore.PushWithClient(ctx, tx, notification.NewCanceledEvent(ctx, &notification.NewAggregate(id, resourceOwner).Aggregate, errorMessage))
return err
}
// NotificationSent writes a new notification.SentEvent with the notification.Aggregate to the eventstore
func (c *Commands) NotificationSent(ctx context.Context, tx *sql.Tx, id, resourceOwner string) error {
_, err := c.eventstore.PushWithClient(ctx, tx, notification.NewSentEvent(ctx, &notification.NewAggregate(id, resourceOwner).Aggregate))
return err
}
// NotificationRetryRequested writes a new notification.RetryRequestEvent with the notification.Aggregate to the eventstore
func (c *Commands) NotificationRetryRequested(ctx context.Context, tx *sql.Tx, id, resourceOwner string, request *NotificationRetryRequest, requestError error) error {
var errorMessage string
if requestError != nil {
errorMessage = requestError.Error()
}
_, err := c.eventstore.PushWithClient(ctx, tx, notification.NewRetryRequestedEvent(ctx, &notification.NewAggregate(id, resourceOwner).Aggregate,
request.UserID,
request.UserResourceOwner,
request.AggregateID,
request.AggregateResourceOwner,
request.TriggerOrigin,
request.URLTemplate,
request.Code,
request.CodeExpiry,
request.EventType,
request.NotificationType,
request.MessageType,
request.UnverifiedNotificationChannel,
request.IsOTP,
request.Args,
request.NotifyUser,
request.BackOff,
errorMessage))
return err
}

View File

@ -96,3 +96,7 @@ func (p *PasswordlessInitCode) Link(baseURL string) string {
func PasswordlessInitCodeLink(baseURL, userID, resourceOwner, codeID, code string) string { func PasswordlessInitCodeLink(baseURL, userID, resourceOwner, codeID, code string) string {
return fmt.Sprintf("%s?userID=%s&orgID=%s&codeID=%s&code=%s", baseURL, userID, resourceOwner, codeID, code) return fmt.Sprintf("%s?userID=%s&orgID=%s&codeID=%s&code=%s", baseURL, userID, resourceOwner, codeID, code)
} }
func PasswordlessInitCodeLinkTemplate(baseURL, userID, resourceOwner, codeID string) string {
return PasswordlessInitCodeLink(baseURL, userID, resourceOwner, codeID, "{{.Code}}")
}

View File

@ -1,5 +1,9 @@
package domain package domain
import (
"time"
)
type NotificationType int32 type NotificationType int32
const ( const (
@ -31,3 +35,32 @@ const (
notificationProviderTypeCount notificationProviderTypeCount
) )
type NotificationArguments struct {
Origin string `json:"origin,omitempty"`
Domain string `json:"domain,omitempty"`
Expiry time.Duration `json:"expiry,omitempty"`
TempUsername string `json:"tempUsername,omitempty"`
ApplicationName string `json:"applicationName,omitempty"`
CodeID string `json:"codeID,omitempty"`
SessionID string `json:"sessionID,omitempty"`
AuthRequestID string `json:"authRequestID,omitempty"`
}
// ToMap creates a type safe map of the notification arguments.
// Since these arguments are used in text template, all keys must be PascalCase and types must remain the same (e.g. Duration).
func (n *NotificationArguments) ToMap() map[string]interface{} {
m := make(map[string]interface{})
if n == nil {
return m
}
m["Origin"] = n.Origin
m["Domain"] = n.Domain
m["Expiry"] = n.Expiry
m["TempUsername"] = n.TempUsername
m["ApplicationName"] = n.ApplicationName
m["CodeID"] = n.CodeID
m["SessionID"] = n.SessionID
m["AuthRequestID"] = n.AuthRequestID
return m
}

View File

@ -7,6 +7,10 @@ import (
"github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/internal/zerrors"
) )
func RenderURLTemplate(w io.Writer, tmpl string, data any) error {
return renderURLTemplate(w, tmpl, data)
}
func renderURLTemplate(w io.Writer, tmpl string, data any) error { func renderURLTemplate(w io.Writer, tmpl string, data any) error {
parsed, err := template.New("").Parse(tmpl) parsed, err := template.New("").Parse(tmpl)
if err != nil { if err != nil {

View File

@ -0,0 +1,25 @@
package channels
import "errors"
type CancelError struct {
Err error
}
func (e *CancelError) Error() string {
return e.Err.Error()
}
func NewCancelError(err error) error {
return &CancelError{
Err: err,
}
}
func (e *CancelError) Is(target error) bool {
return errors.As(target, &e)
}
func (e *CancelError) Unwrap() error {
return e.Err
}

View File

@ -1,7 +1,10 @@
package twilio package twilio
import ( import (
newTwilio "github.com/twilio/twilio-go" "errors"
"github.com/twilio/twilio-go"
twilioClient "github.com/twilio/twilio-go/client"
openapi "github.com/twilio/twilio-go/rest/api/v2010" openapi "github.com/twilio/twilio-go/rest/api/v2010"
verify "github.com/twilio/twilio-go/rest/verify/v2" verify "github.com/twilio/twilio-go/rest/verify/v2"
"github.com/zitadel/logging" "github.com/zitadel/logging"
@ -12,7 +15,7 @@ import (
) )
func InitChannel(config Config) channels.NotificationChannel { func InitChannel(config Config) channels.NotificationChannel {
client := newTwilio.NewRestClientWithParams(newTwilio.ClientParams{Username: config.SID, Password: config.Token}) client := twilio.NewRestClientWithParams(twilio.ClientParams{Username: config.SID, Password: config.Token})
logging.Debug("successfully initialized twilio sms channel") logging.Debug("successfully initialized twilio sms channel")
return channels.HandleMessageFunc(func(message channels.Message) error { return channels.HandleMessageFunc(func(message channels.Message) error {
@ -26,6 +29,17 @@ func InitChannel(config Config) channels.NotificationChannel {
params.SetChannel("sms") params.SetChannel("sms")
resp, err := client.VerifyV2.CreateVerification(config.VerifyServiceSID, params) resp, err := client.VerifyV2.CreateVerification(config.VerifyServiceSID, params)
var twilioErr *twilioClient.TwilioRestError
if errors.As(err, &twilioErr) && twilioErr.Code == 60203 {
// If there were too many attempts to send a verification code (more than 5 times)
// without a verification check, even retries with backoff might not solve the problem.
// Instead, let the user initiate the verification again (e.g. using "resend code")
// https://www.twilio.com/docs/api/errors/60203
logging.WithFields("error", twilioErr.Message, "code", twilioErr.Code).Warn("twilio create verification error")
return channels.NewCancelError(twilioErr)
}
if err != nil { if err != nil {
return zerrors.ThrowInternal(err, "TWILI-0s9f2", "could not send verification") return zerrors.ThrowInternal(err, "TWILI-0s9f2", "could not send verification")
} }

View File

@ -2,13 +2,19 @@ package handlers
import ( import (
"context" "context"
"database/sql"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/notification/senders" "github.com/zitadel/zitadel/internal/notification/senders"
"github.com/zitadel/zitadel/internal/repository/milestone" "github.com/zitadel/zitadel/internal/repository/milestone"
"github.com/zitadel/zitadel/internal/repository/quota" "github.com/zitadel/zitadel/internal/repository/quota"
) )
type Commands interface { 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 HumanInitCodeSent(ctx context.Context, orgID, userID string) error
HumanEmailVerificationCodeSent(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 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, Description: config.Description,
} }
if config.SMTPConfig != nil { 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) password, err := crypto.DecryptString(config.SMTPConfig.Password, n.SMTPPasswordCrypto)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -24,6 +24,9 @@ func (n *NotificationQueries) GetActiveSMSConfig(ctx context.Context) (*sms.Conf
Description: config.Description, Description: config.Description,
} }
if config.TwilioConfig != nil { 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) token, err := crypto.DecryptString(config.TwilioConfig.Token, n.SMSTokenCrypto)
if err != nil { if err != nil {
return nil, err 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}) 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) { func (n *NotificationQueries) HandlerContext(event *eventstore.Aggregate) (context.Context, error) {
ctx := context.Background() ctx := context.Background()
instance, err := n.InstanceByID(ctx, event.InstanceID) instance, err := n.InstanceByID(ctx, event.InstanceID)

View File

@ -11,8 +11,10 @@ package mock
import ( import (
context "context" context "context"
sql "database/sql"
reflect "reflect" reflect "reflect"
command "github.com/zitadel/zitadel/internal/command"
senders "github.com/zitadel/zitadel/internal/notification/senders" senders "github.com/zitadel/zitadel/internal/notification/senders"
milestone "github.com/zitadel/zitadel/internal/repository/milestone" milestone "github.com/zitadel/zitadel/internal/repository/milestone"
quota "github.com/zitadel/zitadel/internal/repository/quota" 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) 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. // OTPEmailSent mocks base method.
func (m *MockCommands) OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error { func (m *MockCommands) OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error {
m.ctrl.T.Helper() 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) 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. // UsageNotificationSent mocks base method.
func (m *MockCommands) UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error { func (m *MockCommands) UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error {
m.ctrl.T.Helper() 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 ( import (
"context" "context"
"strings"
"time" "time"
http_util "github.com/zitadel/zitadel/internal/api/http" 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/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/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/notification/senders" "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/session"
"github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/zerrors" "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 ( const (
UserNotificationsProjectionTable = "projections.notifications" UserNotificationsProjectionTable = "projections.notifications"
) )
@ -26,7 +87,6 @@ const (
type userNotifier struct { type userNotifier struct {
commands Commands commands Commands
queries *NotificationQueries queries *NotificationQueries
channels types.ChannelChains
otpEmailTmpl string otpEmailTmpl string
} }
@ -35,14 +95,12 @@ func NewUserNotifier(
config handler.Config, config handler.Config,
commands Commands, commands Commands,
queries *NotificationQueries, queries *NotificationQueries,
channels types.ChannelChains,
otpEmailTmpl string, otpEmailTmpl string,
) *handler.Handler { ) *handler.Handler {
return handler.NewHandler(ctx, &config, &userNotifier{ return handler.NewHandler(ctx, &config, &userNotifier{
commands: commands, commands: commands,
queries: queries, queries: queries,
otpEmailTmpl: otpEmailTmpl, otpEmailTmpl: otpEmailTmpl,
channels: channels,
}) })
} }
@ -146,39 +204,29 @@ func (u *userNotifier) reduceInitCodeAdded(event eventstore.Event) (*handler.Sta
if alreadyHandled { if alreadyHandled {
return nil 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) ctx, err = u.queries.Origin(ctx, e)
if err != nil { if err != nil {
return err return err
} }
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). origin := http_util.DomainContext(ctx).Origin()
SendUserInitCode(ctx, notifyUser, code, e.AuthRequestID) return u.commands.RequestNotification(
if err != nil { ctx,
return err e.Aggregate().ResourceOwner,
} command.NewNotificationRequest(
return u.commands.HumanInitCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID) 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 }), nil
} }
@ -203,42 +251,39 @@ func (u *userNotifier) reduceEmailCodeAdded(event eventstore.Event) (*handler.St
if alreadyHandled { if alreadyHandled {
return nil 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) ctx, err = u.queries.Origin(ctx, e)
if err != nil { if err != nil {
return err return err
} }
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). origin := http_util.DomainContext(ctx).Origin()
SendEmailVerificationCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID)
if err != nil { return u.commands.RequestNotification(ctx,
return err e.Aggregate().ResourceOwner,
} command.NewNotificationRequest(
return u.commands.HumanEmailVerificationCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID) 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 }), 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) { func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanPasswordCodeAddedEvent) e, ok := event.(*user.HumanPasswordCodeAddedEvent)
if !ok { if !ok {
@ -259,64 +304,74 @@ func (u *userNotifier) reducePasswordCodeAdded(event eventstore.Event) (*handler
if alreadyHandled { if alreadyHandled {
return nil 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) ctx, err = u.queries.Origin(ctx, e)
if err != nil { if err != nil {
return err return err
} }
generatorInfo := new(senders.CodeGeneratorInfo) origin := http_util.DomainContext(ctx).Origin()
notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e) return u.commands.RequestNotification(ctx,
if e.NotificationType == domain.NotificationTypeSms { e.Aggregate().ResourceOwner,
notify = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e, generatorInfo) command.NewNotificationRequest(
} e.Aggregate().ID,
err = notify.SendPasswordCode(ctx, notifyUser, code, e.URLTemplate, e.AuthRequestID) e.Aggregate().ResourceOwner,
if err != nil { origin,
return err e.EventType,
} e.NotificationType,
return u.commands.PasswordCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID, generatorInfo) domain.PasswordResetMessageType,
).
WithURLTemplate(u.passwordCodeTemplate(origin, e)).
WithCode(e.Code, e.Expiry).
WithArgs(&domain.NotificationArguments{
AuthRequestID: e.AuthRequestID,
}).
WithUnverifiedChannel(),
)
}), nil }), 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) { func (u *userNotifier) reduceOTPSMSCodeAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanOTPSMSCodeAddedEvent) e, ok := event.(*user.HumanOTPSMSCodeAddedEvent)
if !ok { if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-ASF3g", "reduce.wrong.event.type %s", user.HumanOTPSMSCodeAddedType) return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-ASF3g", "reduce.wrong.event.type %s", user.HumanOTPSMSCodeAddedType)
} }
return u.reduceOTPSMS(
e, return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error {
e.Code, ctx := HandlerContext(event.Aggregate())
e.Expiry, alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
e.Aggregate().ID, user.HumanOTPSMSCodeAddedType,
e.Aggregate().ResourceOwner, user.HumanOTPSMSCodeSentType)
u.commands.HumanOTPSMSCodeSent, if err != nil {
user.HumanOTPSMSCodeAddedType, return err
user.HumanOTPSMSCodeSentType, }
) 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) { 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 { if e.CodeReturned {
return handler.NewNoOpStatement(e), nil 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( return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error {
event eventstore.Event, ctx := HandlerContext(event.Aggregate())
code *crypto.CryptoValue, alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
expiry time.Duration, session.OTPSMSChallengedType,
userID, session.OTPSMSSentType)
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)
if err != nil { 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) ctx, err = u.queries.Origin(ctx, e)
if err != nil { if err != nil {
return nil, err return err
} }
translator, err := u.queries.GetTranslatorWithOrgTexts(ctx, notifyUser.ResourceOwner, domain.VerifySMSOTPMessageType)
if err != nil { args := otpArgs(ctx, e.Expiry)
return nil, err args.SessionID = e.Aggregate().ID
} return u.commands.RequestNotification(ctx,
ctx, err = u.queries.Origin(ctx, event) s.UserFactor.ResourceOwner,
if err != nil { command.NewNotificationRequest(
return nil, err s.UserFactor.UserID,
} s.UserFactor.ResourceOwner,
generatorInfo := new(senders.CodeGeneratorInfo) http_util.DomainContext(ctx).Origin(),
notify := types.SendSMS(ctx, u.channels, translator, notifyUser, colors, event, generatorInfo) e.EventType,
err = notify.SendOTPSMSCode(ctx, plainCode, expiry) domain.NotificationTypeSms,
if err != nil { domain.VerifySMSOTPMessageType,
return nil, err ).
} WithAggregate(e.Aggregate().ID, e.Aggregate().ResourceOwner).
err = sentCommand(ctx, event.Aggregate().ID, event.Aggregate().ResourceOwner, generatorInfo) WithCode(e.Code, e.Expiry).
if err != nil { WithOTP().
return nil, err WithArgs(args),
} )
return handler.NewNoOpStatement(event), nil }), nil
} }
func (u *userNotifier) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) { func (u *userNotifier) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler.Statement, error) {
@ -403,24 +429,46 @@ func (u *userNotifier) reduceOTPEmailCodeAdded(event eventstore.Event) (*handler
if !ok { if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-JL3hw", "reduce.wrong.event.type %s", user.HumanOTPEmailCodeAddedType) return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-JL3hw", "reduce.wrong.event.type %s", user.HumanOTPEmailCodeAddedType)
} }
var authRequestID string
if e.AuthRequestInfo != nil { return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error {
authRequestID = e.AuthRequestInfo.ID ctx := HandlerContext(event.Aggregate())
} alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
url := func(code, origin string, _ *query.NotifyUser) (string, error) { user.HumanOTPEmailCodeAddedType,
return login.OTPLink(origin, authRequestID, code, domain.MFATypeOTPEmail), nil user.HumanOTPEmailCodeSentType)
} if err != nil {
return u.reduceOTPEmail( return err
e, }
e.Code, if alreadyHandled {
e.Expiry, return nil
e.Aggregate().ID, }
e.Aggregate().ResourceOwner,
url, ctx, err = u.queries.Origin(ctx, e)
u.commands.HumanOTPEmailCodeSent, if err != nil {
user.HumanOTPEmailCodeAddedType, return err
user.HumanOTPEmailCodeSentType, }
) 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) { func (u *userNotifier) reduceSessionOTPEmailChallenged(event eventstore.Event) (*handler.Statement, error) {
@ -431,93 +479,63 @@ func (u *userNotifier) reduceSessionOTPEmailChallenged(event eventstore.Event) (
if e.ReturnCode { if e.ReturnCode {
return handler.NewNoOpStatement(e), nil return handler.NewNoOpStatement(e), nil
} }
ctx := HandlerContext(event.Aggregate()) return handler.NewStatement(event, func(ex handler.Executer, projectionName string) error {
s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "") ctx := HandlerContext(event.Aggregate())
if err != nil { alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, nil,
return nil, err session.OTPEmailChallengedType,
} session.OTPEmailSentType)
url := func(code, origin string, user *query.NotifyUser) (string, error) { if err != nil {
var buf strings.Builder return err
urlTmpl := origin + u.otpEmailTmpl
if e.URLTmpl != "" {
urlTmpl = e.URLTmpl
} }
if err := domain.RenderOTPEmailURLTemplate(&buf, urlTmpl, code, user.ID, user.PreferredLoginName, user.DisplayName, e.Aggregate().ID, user.PreferredLanguage); err != nil { if alreadyHandled {
return "", err return nil
} }
return buf.String(), nil s, err := u.queries.SessionByID(ctx, true, e.Aggregate().ID, "")
} if err != nil {
return u.reduceOTPEmail( return err
e, }
e.Code,
e.Expiry, ctx, err = u.queries.Origin(ctx, e)
s.UserFactor.UserID, if err != nil {
s.UserFactor.ResourceOwner, return err
url, }
u.commands.OTPEmailSent, origin := http_util.DomainContext(ctx).Origin()
user.HumanOTPEmailCodeAddedType,
user.HumanOTPEmailCodeSentType, 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( func (u *userNotifier) otpEmailTemplate(origin string, e *session.OTPEmailChallengedEvent) string {
event eventstore.Event, if e.URLTmpl != "" {
code *crypto.CryptoValue, return e.URLTmpl
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
} }
return origin + u.otpEmailTmpl
}
template, err := u.queries.MailTemplateByOrg(ctx, resourceOwner, false) func otpArgs(ctx context.Context, expiry time.Duration) *domain.NotificationArguments {
if err != nil { domainCtx := http_util.DomainContext(ctx)
return nil, err 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) { 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 { if alreadyHandled {
return nil 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) ctx, err = u.queries.Origin(ctx, e)
if err != nil { if err != nil {
return err return err
} }
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). origin := http_util.DomainContext(ctx).Origin()
SendDomainClaimed(ctx, notifyUser, e.UserName) return u.commands.RequestNotification(ctx,
if err != nil { e.Aggregate().ResourceOwner,
return err command.NewNotificationRequest(
} e.Aggregate().ID,
return u.commands.UserDomainClaimedSent(ctx, e.Aggregate().ResourceOwner, 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 }), nil
} }
@ -585,42 +596,37 @@ func (u *userNotifier) reducePasswordlessCodeRequested(event eventstore.Event) (
if alreadyHandled { if alreadyHandled {
return nil 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) ctx, err = u.queries.Origin(ctx, e)
if err != nil { if err != nil {
return err return err
} }
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). origin := http_util.DomainContext(ctx).Origin()
SendPasswordlessRegistrationLink(ctx, notifyUser, code, e.ID, e.URLTemplate) return u.commands.RequestNotification(ctx,
if err != nil { e.Aggregate().ResourceOwner,
return err command.NewNotificationRequest(
} e.Aggregate().ID,
return u.commands.HumanPasswordlessInitCodeSent(ctx, e.Aggregate().ID, e.Aggregate().ResourceOwner, e.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 }), 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) { func (u *userNotifier) reducePasswordChanged(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*user.HumanPasswordChangedEvent) e, ok := event.(*user.HumanPasswordChangedEvent)
if !ok { 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) notificationPolicy, err := u.queries.NotificationPolicyByOrg(ctx, true, e.Aggregate().ResourceOwner, false)
if zerrors.IsNotFound(err) { if err != nil && !zerrors.IsNotFound(err) {
return nil
}
if err != nil {
return err return err
} }
@ -649,34 +652,25 @@ func (u *userNotifier) reducePasswordChanged(event eventstore.Event) (*handler.S
return nil 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) ctx, err = u.queries.Origin(ctx, e)
if err != nil { if err != nil {
return err return err
} }
err = types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e). origin := http_util.DomainContext(ctx).Origin()
SendPasswordChange(ctx, notifyUser)
if err != nil { return u.commands.RequestNotification(ctx,
return err e.Aggregate().ResourceOwner,
} command.NewNotificationRequest(
return u.commands.PasswordChangeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID) e.Aggregate().ID,
e.Aggregate().ResourceOwner,
origin,
e.EventType,
domain.NotificationTypeEmail,
domain.PasswordChangeMessageType,
).
WithURLTemplate(console.LoginHintLink(origin, "{{.PreferredLoginName}}")).
WithUnverifiedChannel(),
)
}), nil }), nil
} }
@ -700,37 +694,28 @@ func (u *userNotifier) reducePhoneCodeAdded(event eventstore.Event) (*handler.St
if alreadyHandled { if alreadyHandled {
return nil 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) ctx, err = u.queries.Origin(ctx, e)
if err != nil { if err != nil {
return err return err
} }
generatorInfo := new(senders.CodeGeneratorInfo)
if err = types.SendSMS(ctx, u.channels, translator, notifyUser, colors, e, generatorInfo). return u.commands.RequestNotification(ctx,
SendPhoneVerificationCode(ctx, code); err != nil { e.Aggregate().ResourceOwner,
return err command.NewNotificationRequest(
} e.Aggregate().ID,
return u.commands.HumanPhoneVerificationCodeSent(ctx, e.Aggregate().ResourceOwner, e.Aggregate().ID, generatorInfo) 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 }), nil
} }
@ -753,42 +738,45 @@ func (u *userNotifier) reduceInviteCodeAdded(event eventstore.Event) (*handler.S
if alreadyHandled { if alreadyHandled {
return nil 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) ctx, err = u.queries.Origin(ctx, e)
if err != nil { if err != nil {
return err return err
} }
notify := types.SendEmail(ctx, u.channels, string(template.Template), translator, notifyUser, colors, e) origin := http_util.DomainContext(ctx).Origin()
err = notify.SendInviteCode(ctx, notifyUser, code, e.ApplicationName, e.URLTemplate, e.AuthRequestID)
if err != nil { applicationName := e.ApplicationName
return err 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 }), 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) { 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()) { if expiry > 0 && event.CreatedAt().Add(expiry).Before(time.Now().UTC()) {
return true, nil return true, nil

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ import (
"github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/notification/handlers" "github.com/zitadel/zitadel/internal/notification/handlers"
@ -14,11 +15,15 @@ import (
"github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/query/projection"
) )
var projections []*handler.Handler var (
projections []*handler.Handler
worker *handlers.NotificationWorker
)
func Register( func Register(
ctx context.Context, ctx context.Context,
userHandlerCustomConfig, quotaHandlerCustomConfig, telemetryHandlerCustomConfig, backChannelLogoutHandlerCustomConfig projection.CustomConfig, userHandlerCustomConfig, quotaHandlerCustomConfig, telemetryHandlerCustomConfig, backChannelLogoutHandlerCustomConfig projection.CustomConfig,
notificationWorkerConfig handlers.WorkerConfig,
telemetryCfg handlers.TelemetryPusherConfig, telemetryCfg handlers.TelemetryPusherConfig,
externalDomain string, externalDomain string,
externalPort uint16, externalPort uint16,
@ -29,10 +34,11 @@ func Register(
otpEmailTmpl, fileSystemPath string, otpEmailTmpl, fileSystemPath string,
userEncryption, smtpEncryption, smsEncryption, keysEncryptionAlg crypto.EncryptionAlgorithm, userEncryption, smtpEncryption, smsEncryption, keysEncryptionAlg crypto.EncryptionAlgorithm,
tokenLifetime time.Duration, tokenLifetime time.Duration,
client *database.DB,
) { ) {
q := handlers.NewNotificationQueries(queries, es, externalDomain, externalPort, externalSecure, fileSystemPath, userEncryption, smtpEncryption, smsEncryption) q := handlers.NewNotificationQueries(queries, es, externalDomain, externalPort, externalSecure, fileSystemPath, userEncryption, smtpEncryption, smsEncryption)
c := newChannels(q) c := newChannels(q)
projections = append(projections, handlers.NewUserNotifier(ctx, projection.ApplyCustomConfig(userHandlerCustomConfig), commands, q, c, otpEmailTmpl)) projections = append(projections, handlers.NewUserNotifier(ctx, projection.ApplyCustomConfig(userHandlerCustomConfig), commands, q, otpEmailTmpl))
projections = append(projections, handlers.NewQuotaNotifier(ctx, projection.ApplyCustomConfig(quotaHandlerCustomConfig), commands, q, c)) projections = append(projections, handlers.NewQuotaNotifier(ctx, projection.ApplyCustomConfig(quotaHandlerCustomConfig), commands, q, c))
projections = append(projections, handlers.NewBackChannelLogoutNotifier( projections = append(projections, handlers.NewBackChannelLogoutNotifier(
ctx, ctx,
@ -47,12 +53,14 @@ func Register(
if telemetryCfg.Enabled { if telemetryCfg.Enabled {
projections = append(projections, handlers.NewTelemetryPusher(ctx, telemetryCfg, projection.ApplyCustomConfig(telemetryHandlerCustomConfig), commands, q, c)) projections = append(projections, handlers.NewTelemetryPusher(ctx, telemetryCfg, projection.ApplyCustomConfig(telemetryHandlerCustomConfig), commands, q, c))
} }
worker = handlers.NewNotificationWorker(notificationWorkerConfig, commands, q, es, client, c)
} }
func Start(ctx context.Context) { func Start(ctx context.Context) {
for _, projection := range projections { for _, projection := range projections {
projection.Start(ctx) projection.Start(ctx)
} }
worker.Start(ctx)
} }
func ProjectInstance(ctx context.Context) error { func ProjectInstance(ctx context.Context) error {

View File

@ -1,20 +0,0 @@
package types
import (
"context"
"strings"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendDomainClaimed(ctx context.Context, user *query.NotifyUser, username string) error {
url := login.LoginLink(http_utils.DomainContext(ctx).Origin(), user.ResourceOwner)
index := strings.LastIndex(user.LastEmail, "@")
args := make(map[string]interface{})
args["TempUsername"] = username
args["Domain"] = user.LastEmail[index+1:]
return notify(url, args, domain.DomainClaimedMessageType, true)
}

View File

@ -1,28 +0,0 @@
package types
import (
"context"
"strings"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendEmailVerificationCode(ctx context.Context, user *query.NotifyUser, code string, urlTmpl, authRequestID string) error {
var url string
if urlTmpl == "" {
url = login.MailVerificationLink(http_utils.DomainContext(ctx).Origin(), user.ID, code, user.ResourceOwner, authRequestID)
} else {
var buf strings.Builder
if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil {
return err
}
url = buf.String()
}
args := make(map[string]interface{})
args["Code"] = code
return notify(url, args, domain.VerifyEmailMessageType, true)
}

View File

@ -1,92 +0,0 @@
package types
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestNotify_SendEmailVerificationCode(t *testing.T) {
type args struct {
user *query.NotifyUser
origin *http_utils.DomainCtx
code string
urlTmpl string
authRequestID string
}
tests := []struct {
name string
args args
want *notifyResult
wantErr error
}{
{
name: "default URL",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"},
code: "123",
urlTmpl: "",
authRequestID: "authRequestID",
},
want: &notifyResult{
url: "https://example.com/ui/login/mail/verification?authRequestID=authRequestID&code=123&orgID=org1&userID=user1",
args: map[string]interface{}{"Code": "123"},
messageType: domain.VerifyEmailMessageType,
allowUnverifiedNotificationChannel: true,
},
},
{
name: "template error",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"},
code: "123",
urlTmpl: "{{",
authRequestID: "authRequestID",
},
want: &notifyResult{},
wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
},
{
name: "template success",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"},
code: "123",
urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
authRequestID: "authRequestID",
},
want: &notifyResult{
url: "https://example.com/email/verify?userID=user1&code=123&orgID=org1",
args: map[string]interface{}{"Code": "123"},
messageType: domain.VerifyEmailMessageType,
allowUnverifiedNotificationChannel: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, notify := mockNotify()
err := notify.SendEmailVerificationCode(http_utils.WithDomainContext(context.Background(), tt.args.origin), tt.args.user, tt.args.code, tt.args.urlTmpl, tt.args.authRequestID)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}

View File

@ -1,17 +0,0 @@
package types
import (
"context"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendUserInitCode(ctx context.Context, user *query.NotifyUser, code, authRequestID string) error {
url := login.InitUserLink(http_utils.DomainContext(ctx).Origin(), user.ID, user.PreferredLoginName, code, user.ResourceOwner, user.PasswordSet, authRequestID)
args := make(map[string]interface{})
args["Code"] = code
return notify(url, args, domain.InitCodeMessageType, true)
}

View File

@ -1,31 +0,0 @@
package types
import (
"context"
"strings"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendInviteCode(ctx context.Context, user *query.NotifyUser, code, applicationName, urlTmpl, authRequestID string) error {
var url string
if applicationName == "" {
applicationName = "ZITADEL"
}
if urlTmpl == "" {
url = login.InviteUserLink(http_utils.DomainContext(ctx).Origin(), user.ID, user.PreferredLoginName, code, user.ResourceOwner, authRequestID)
} else {
var buf strings.Builder
if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil {
return err
}
url = buf.String()
}
args := make(map[string]interface{})
args["Code"] = code
args["ApplicationName"] = applicationName
return notify(url, args, domain.InviteUserMessageType, true)
}

View File

@ -3,8 +3,10 @@ package types
import ( import (
"context" "context"
"html" "html"
"strings"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/i18n" "github.com/zitadel/zitadel/internal/i18n"
"github.com/zitadel/zitadel/internal/notification/channels/email" "github.com/zitadel/zitadel/internal/notification/channels/email"
@ -40,13 +42,17 @@ func SendEmail(
triggeringEvent eventstore.Event, triggeringEvent eventstore.Event,
) Notify { ) Notify {
return func( return func(
url string, urlTmpl string,
args map[string]interface{}, args map[string]interface{},
messageType string, messageType string,
allowUnverifiedNotificationChannel bool, allowUnverifiedNotificationChannel bool,
) error { ) error {
args = mapNotifyUserToArgs(user, args) args = mapNotifyUserToArgs(user, args)
sanitizeArgsForHTML(args) sanitizeArgsForHTML(args)
url, err := urlFromTemplate(urlTmpl, args)
if err != nil {
return err
}
data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors) data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors)
template, err := templates.GetParsedTemplate(mailhtml, data) template, err := templates.GetParsedTemplate(mailhtml, data)
if err != nil { if err != nil {
@ -82,6 +88,14 @@ func sanitizeArgsForHTML(args map[string]any) {
} }
} }
func urlFromTemplate(urlTmpl string, args map[string]interface{}) (string, error) {
var buf strings.Builder
if err := domain.RenderURLTemplate(&buf, urlTmpl, args); err != nil {
return "", err
}
return buf.String(), nil
}
func SendSMS( func SendSMS(
ctx context.Context, ctx context.Context,
channels ChannelChains, channels ChannelChains,
@ -92,12 +106,16 @@ func SendSMS(
generatorInfo *senders.CodeGeneratorInfo, generatorInfo *senders.CodeGeneratorInfo,
) Notify { ) Notify {
return func( return func(
url string, urlTmpl string,
args map[string]interface{}, args map[string]interface{},
messageType string, messageType string,
allowUnverifiedNotificationChannel bool, allowUnverifiedNotificationChannel bool,
) error { ) error {
args = mapNotifyUserToArgs(user, args) args = mapNotifyUserToArgs(user, args)
url, err := urlFromTemplate(urlTmpl, args)
if err != nil {
return err
}
data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors) data := GetTemplateData(ctx, translator, args, url, messageType, user.PreferredLanguage.String(), colors)
return generateSms( return generateSms(
ctx, ctx,

View File

@ -1,29 +0,0 @@
package types
import (
"context"
"time"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/domain"
)
func (notify Notify) SendOTPSMSCode(ctx context.Context, code string, expiry time.Duration) error {
args := otpArgs(ctx, code, expiry)
return notify("", args, domain.VerifySMSOTPMessageType, false)
}
func (notify Notify) SendOTPEmailCode(ctx context.Context, url, code string, expiry time.Duration) error {
args := otpArgs(ctx, code, expiry)
return notify(url, args, domain.VerifyEmailOTPMessageType, false)
}
func otpArgs(ctx context.Context, code string, expiry time.Duration) map[string]interface{} {
domainCtx := http_utils.DomainContext(ctx)
args := make(map[string]interface{})
args["OTP"] = code
args["Origin"] = domainCtx.Origin()
args["Domain"] = domainCtx.RequestedDomain()
args["Expiry"] = expiry
return args
}

View File

@ -1,16 +0,0 @@
package types
import (
"context"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/console"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendPasswordChange(ctx context.Context, user *query.NotifyUser) error {
url := console.LoginHintLink(http_utils.DomainContext(ctx).Origin(), user.PreferredLoginName)
args := make(map[string]interface{})
return notify(url, args, domain.PasswordChangeMessageType, true)
}

View File

@ -1,27 +0,0 @@
package types
import (
"context"
"strings"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendPasswordCode(ctx context.Context, user *query.NotifyUser, code, urlTmpl, authRequestID string) error {
var url string
if urlTmpl == "" {
url = login.InitPasswordLink(http_utils.DomainContext(ctx).Origin(), user.ID, code, user.ResourceOwner, authRequestID)
} else {
var buf strings.Builder
if err := domain.RenderConfirmURLTemplate(&buf, urlTmpl, user.ID, code, user.ResourceOwner); err != nil {
return err
}
url = buf.String()
}
args := make(map[string]interface{})
args["Code"] = code
return notify(url, args, domain.PasswordResetMessageType, true)
}

View File

@ -1,25 +0,0 @@
package types
import (
"context"
"strings"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func (notify Notify) SendPasswordlessRegistrationLink(ctx context.Context, user *query.NotifyUser, code, codeID, urlTmpl string) error {
var url string
if urlTmpl == "" {
url = domain.PasswordlessInitCodeLink(http_utils.DomainContext(ctx).Origin()+login.HandlerPrefix+login.EndpointPasswordlessRegistration, user.ID, user.ResourceOwner, codeID, code)
} else {
var buf strings.Builder
if err := domain.RenderPasskeyURLTemplate(&buf, urlTmpl, user.ID, user.ResourceOwner, codeID, code); err != nil {
return err
}
url = buf.String()
}
return notify(url, nil, domain.PasswordlessRegistrationMessageType, true)
}

View File

@ -1,90 +0,0 @@
package types
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
http_utils "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestNotify_SendPasswordlessRegistrationLink(t *testing.T) {
type args struct {
user *query.NotifyUser
origin *http_utils.DomainCtx
code string
codeID string
urlTmpl string
}
tests := []struct {
name string
args args
want *notifyResult
wantErr error
}{
{
name: "default URL",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"},
code: "123",
codeID: "456",
urlTmpl: "",
},
want: &notifyResult{
url: "https://example.com/ui/login/login/passwordless/init?userID=user1&orgID=org1&codeID=456&code=123",
messageType: domain.PasswordlessRegistrationMessageType,
allowUnverifiedNotificationChannel: true,
},
},
{
name: "template error",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"},
code: "123",
codeID: "456",
urlTmpl: "{{",
},
want: &notifyResult{},
wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
},
{
name: "template success",
args: args{
user: &query.NotifyUser{
ID: "user1",
ResourceOwner: "org1",
},
origin: &http_utils.DomainCtx{InstanceHost: "example.com", Protocol: "https"},
code: "123",
codeID: "456",
urlTmpl: "https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}",
},
want: &notifyResult{
url: "https://example.com/passkey/register?userID=user1&orgID=org1&codeID=456&code=123",
messageType: domain.PasswordlessRegistrationMessageType,
allowUnverifiedNotificationChannel: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, notify := mockNotify()
err := notify.SendPasswordlessRegistrationLink(http_utils.WithDomainContext(context.Background(), tt.args.origin), tt.args.user, tt.args.code, tt.args.codeID, tt.args.urlTmpl)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}

View File

@ -1,15 +0,0 @@
package types
import (
"context"
http_util "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/domain"
)
func (notify Notify) SendPhoneVerificationCode(ctx context.Context, code string) error {
args := make(map[string]interface{})
args["Code"] = code
args["Domain"] = http_util.DomainContext(ctx).RequestedDomain()
return notify("", args, domain.VerifyPhoneMessageType, true)
}

View File

@ -1,23 +0,0 @@
package types
type notifyResult struct {
url string
args map[string]interface{}
messageType string
allowUnverifiedNotificationChannel bool
}
// mockNotify returns a notifyResult and Notify function for easy mocking.
// The notifyResult will only be populated after Notify is called.
func mockNotify() (*notifyResult, Notify) {
dst := new(notifyResult)
return dst, func(url string, args map[string]interface{}, messageType string, allowUnverifiedNotificationChannel bool) error {
*dst = notifyResult{
url: url,
args: args,
messageType: messageType,
allowUnverifiedNotificationChannel: allowUnverifiedNotificationChannel,
}
return nil
}
}

View File

@ -74,6 +74,8 @@ func mapNotifyUserToArgs(user *query.NotifyUser, args map[string]interface{}) ma
if args == nil { if args == nil {
args = make(map[string]interface{}) args = make(map[string]interface{})
} }
args["UserID"] = user.ID
args["OrgID"] = user.ResourceOwner
args["UserName"] = user.Username args["UserName"] = user.Username
args["FirstName"] = user.FirstName args["FirstName"] = user.FirstName
args["LastName"] = user.LastName args["LastName"] = user.LastName
@ -84,6 +86,7 @@ func mapNotifyUserToArgs(user *query.NotifyUser, args map[string]interface{}) ma
args["LastPhone"] = user.LastPhone args["LastPhone"] = user.LastPhone
args["VerifiedPhone"] = user.VerifiedPhone args["VerifiedPhone"] = user.VerifiedPhone
args["PreferredLoginName"] = user.PreferredLoginName args["PreferredLoginName"] = user.PreferredLoginName
args["LoginName"] = user.PreferredLoginName // some endpoint promoted LoginName instead of PreferredLoginName
args["LoginNames"] = user.LoginNames args["LoginNames"] = user.LoginNames
args["ChangeDate"] = user.ChangeDate args["ChangeDate"] = user.ChangeDate
args["CreationDate"] = user.CreationDate args["CreationDate"] = user.CreationDate

View File

@ -0,0 +1,25 @@
package notification
import (
"github.com/zitadel/zitadel/internal/eventstore"
)
const (
AggregateType = "notification"
AggregateVersion = "v1"
)
type Aggregate struct {
eventstore.Aggregate
}
func NewAggregate(id, resourceOwner string) *Aggregate {
return &Aggregate{
Aggregate: eventstore.Aggregate{
Type: AggregateType,
Version: AggregateVersion,
ID: id,
ResourceOwner: resourceOwner,
},
}
}

View File

@ -0,0 +1,12 @@
package notification
import (
"github.com/zitadel/zitadel/internal/eventstore"
)
func init() {
eventstore.RegisterFilterEventMapper(AggregateType, RequestedType, eventstore.GenericEventMapper[RequestedEvent])
eventstore.RegisterFilterEventMapper(AggregateType, SentType, eventstore.GenericEventMapper[SentEvent])
eventstore.RegisterFilterEventMapper(AggregateType, RetryRequestedType, eventstore.GenericEventMapper[RetryRequestedEvent])
eventstore.RegisterFilterEventMapper(AggregateType, CanceledType, eventstore.GenericEventMapper[CanceledEvent])
}

View File

@ -0,0 +1,244 @@
package notification
import (
"context"
"time"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/query"
)
const (
notificationEventPrefix = "notification."
RequestedType = notificationEventPrefix + "requested"
RetryRequestedType = notificationEventPrefix + "retry.requested"
SentType = notificationEventPrefix + "sent"
CanceledType = notificationEventPrefix + "canceled"
)
type Request struct {
UserID string `json:"userID"`
UserResourceOwner string `json:"userResourceOwner"`
AggregateID string `json:"notificationAggregateID"`
AggregateResourceOwner string `json:"notificationAggregateResourceOwner"`
TriggeredAtOrigin string `json:"triggeredAtOrigin"`
EventType eventstore.EventType `json:"eventType"`
MessageType string `json:"messageType"`
NotificationType domain.NotificationType `json:"notificationType"`
URLTemplate string `json:"urlTemplate,omitempty"`
CodeExpiry time.Duration `json:"codeExpiry,omitempty"`
Code *crypto.CryptoValue `json:"code,omitempty"`
UnverifiedNotificationChannel bool `json:"unverifiedNotificationChannel,omitempty"`
IsOTP bool `json:"isOTP,omitempty"`
RequiresPreviousDomain bool `json:"RequiresPreviousDomain,omitempty"`
Args *domain.NotificationArguments `json:"args,omitempty"`
}
func (e *Request) NotificationAggregateID() string {
if e.AggregateID == "" {
return e.UserID
}
return e.AggregateID
}
func (e *Request) NotificationAggregateResourceOwner() string {
if e.AggregateResourceOwner == "" {
return e.UserResourceOwner
}
return e.AggregateResourceOwner
}
type RequestedEvent struct {
eventstore.BaseEvent `json:"-"`
Request `json:"request"`
}
func (e *RequestedEvent) TriggerOrigin() string {
return e.TriggeredAtOrigin
}
func (e *RequestedEvent) Payload() interface{} {
return e
}
func (e *RequestedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return nil
}
func (e *RequestedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewRequestedEvent(ctx context.Context,
aggregate *eventstore.Aggregate,
userID,
userResourceOwner,
aggregateID,
aggregateResourceOwner,
triggerOrigin,
urlTemplate string,
code *crypto.CryptoValue,
codeExpiry time.Duration,
eventType eventstore.EventType,
notificationType domain.NotificationType,
messageType string,
unverifiedNotificationChannel,
isOTP,
requiresPreviousDomain bool,
args *domain.NotificationArguments,
) *RequestedEvent {
return &RequestedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
RequestedType,
),
Request: Request{
UserID: userID,
UserResourceOwner: userResourceOwner,
AggregateID: aggregateID,
AggregateResourceOwner: aggregateResourceOwner,
TriggeredAtOrigin: triggerOrigin,
EventType: eventType,
MessageType: messageType,
NotificationType: notificationType,
URLTemplate: urlTemplate,
CodeExpiry: codeExpiry,
Code: code,
UnverifiedNotificationChannel: unverifiedNotificationChannel,
IsOTP: isOTP,
RequiresPreviousDomain: requiresPreviousDomain,
Args: args,
},
}
}
type SentEvent struct {
eventstore.BaseEvent `json:"-"`
}
func (e *SentEvent) Payload() interface{} {
return e
}
func (e *SentEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return nil
}
func (e *SentEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewSentEvent(ctx context.Context,
aggregate *eventstore.Aggregate,
) *SentEvent {
return &SentEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
SentType,
),
}
}
type CanceledEvent struct {
eventstore.BaseEvent `json:"-"`
Error string `json:"error"`
}
func (e *CanceledEvent) Payload() interface{} {
return e
}
func (e *CanceledEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return nil
}
func (e *CanceledEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewCanceledEvent(ctx context.Context, aggregate *eventstore.Aggregate, errorMessage string) *CanceledEvent {
return &CanceledEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
CanceledType,
),
Error: errorMessage,
}
}
type RetryRequestedEvent struct {
eventstore.BaseEvent `json:"-"`
Request `json:"request"`
Error string `json:"error"`
NotifyUser *query.NotifyUser `json:"notifyUser"`
BackOff time.Duration `json:"backOff"`
}
func (e *RetryRequestedEvent) Payload() interface{} {
return e
}
func (e *RetryRequestedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return nil
}
func (e *RetryRequestedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
e.BaseEvent = *event
}
func NewRetryRequestedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
userID,
userResourceOwner,
aggregateID,
aggregateResourceOwner,
triggerOrigin,
urlTemplate string,
code *crypto.CryptoValue,
codeExpiry time.Duration,
eventType eventstore.EventType,
notificationType domain.NotificationType,
messageType string,
unverifiedNotificationChannel,
isOTP bool,
args *domain.NotificationArguments,
notifyUser *query.NotifyUser,
backoff time.Duration,
errorMessage string,
) *RetryRequestedEvent {
return &RetryRequestedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
RetryRequestedType,
),
Request: Request{
UserID: userID,
UserResourceOwner: userResourceOwner,
AggregateID: aggregateID,
AggregateResourceOwner: aggregateResourceOwner,
TriggeredAtOrigin: triggerOrigin,
EventType: eventType,
MessageType: messageType,
NotificationType: notificationType,
URLTemplate: urlTemplate,
CodeExpiry: codeExpiry,
Code: code,
UnverifiedNotificationChannel: unverifiedNotificationChannel,
IsOTP: isOTP,
Args: args,
},
NotifyUser: notifyUser,
BackOff: backoff,
Error: errorMessage,
}
}