diff --git a/backend/config/config.go b/backend/config/config.go index 54a38961d..9d6eaaf77 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -20,23 +20,24 @@ import ( // Config is the central configuration type type Config struct { - Server Server `yaml:"server" json:"server,omitempty" koanf:"server"` - Webauthn WebauthnSettings `yaml:"webauthn" json:"webauthn,omitempty" koanf:"webauthn"` - Smtp SMTP `yaml:"smtp" json:"smtp,omitempty" koanf:"smtp"` - Passcode Passcode `yaml:"passcode" json:"passcode" koanf:"passcode"` - Password Password `yaml:"password" json:"password,omitempty" koanf:"password"` - Database Database `yaml:"database" json:"database" koanf:"database"` - Secrets Secrets `yaml:"secrets" json:"secrets" koanf:"secrets"` - Service Service `yaml:"service" json:"service" koanf:"service"` - Session Session `yaml:"session" json:"session,omitempty" koanf:"session"` - AuditLog AuditLog `yaml:"audit_log" json:"audit_log,omitempty" koanf:"audit_log" split_words:"true"` - Emails Emails `yaml:"emails" json:"emails,omitempty" koanf:"emails"` - RateLimiter RateLimiter `yaml:"rate_limiter" json:"rate_limiter,omitempty" koanf:"rate_limiter" split_words:"true"` - ThirdParty ThirdParty `yaml:"third_party" json:"third_party,omitempty" koanf:"third_party" split_words:"true"` - Log LoggerConfig `yaml:"log" json:"log,omitempty" koanf:"log"` - Account Account `yaml:"account" json:"account,omitempty" koanf:"account"` - Saml config.Saml `yaml:"saml" json:"saml,omitempty" koanf:"saml"` - Webhooks WebhookSettings `yaml:"webhooks" json:"webhooks,omitempty" koanf:"webhooks"` + Server Server `yaml:"server" json:"server,omitempty" koanf:"server"` + Webauthn WebauthnSettings `yaml:"webauthn" json:"webauthn,omitempty" koanf:"webauthn"` + Smtp SMTP `yaml:"smtp" json:"smtp,omitempty" koanf:"smtp"` + EmailDelivery EmailDelivery `yaml:"email_delivery" json:"email_delivery,omitempty" koanf:"email_delivery" split_words:"true"` + Passcode Passcode `yaml:"passcode" json:"passcode" koanf:"passcode"` + Password Password `yaml:"password" json:"password,omitempty" koanf:"password"` + Database Database `yaml:"database" json:"database" koanf:"database"` + Secrets Secrets `yaml:"secrets" json:"secrets" koanf:"secrets"` + Service Service `yaml:"service" json:"service" koanf:"service"` + Session Session `yaml:"session" json:"session,omitempty" koanf:"session"` + AuditLog AuditLog `yaml:"audit_log" json:"audit_log,omitempty" koanf:"audit_log" split_words:"true"` + Emails Emails `yaml:"emails" json:"emails,omitempty" koanf:"emails"` + RateLimiter RateLimiter `yaml:"rate_limiter" json:"rate_limiter,omitempty" koanf:"rate_limiter" split_words:"true"` + ThirdParty ThirdParty `yaml:"third_party" json:"third_party,omitempty" koanf:"third_party" split_words:"true"` + Log LoggerConfig `yaml:"log" json:"log,omitempty" koanf:"log"` + Account Account `yaml:"account" json:"account,omitempty" koanf:"account"` + Saml config.Saml `yaml:"saml" json:"saml,omitempty" koanf:"saml"` + Webhooks WebhookSettings `yaml:"webhooks" json:"webhooks,omitempty" koanf:"webhooks"` } var ( @@ -118,6 +119,9 @@ func DefaultConfig() *Config { Smtp: SMTP{ Port: "465", }, + EmailDelivery: EmailDelivery{ + Enabled: true, + }, Passcode: Passcode{ TTL: 300, Email: Email{ @@ -200,9 +204,11 @@ func (c *Config) Validate() error { if err != nil { return fmt.Errorf("failed to validate webauthn settings: %w", err) } - err = c.Smtp.Validate() - if err != nil { - return fmt.Errorf("failed to validate smtp settings: %w", err) + if c.EmailDelivery.Enabled { + err = c.Smtp.Validate() + if err != nil { + return fmt.Errorf("failed to validate smtp settings: %w", err) + } } err = c.Passcode.Validate() if err != nil { @@ -379,6 +385,10 @@ func (s *SMTP) Validate() error { return nil } +type EmailDelivery struct { + Enabled bool `yaml:"enabled" json:"enabled" koanf:"enabled" jsonschema:"default=true"` +} + type Email struct { FromAddress string `yaml:"from_address" json:"from_address,omitempty" koanf:"from_address" split_words:"true" jsonschema:"default=passcode@hanko.io"` FromName string `yaml:"from_name" json:"from_name,omitempty" koanf:"from_name" split_words:"true" jsonschema:"default=Hanko"` @@ -684,6 +694,9 @@ func (c *Config) PostProcess() error { } func (c *Config) arrangeSmtpSettings() { + if !c.EmailDelivery.Enabled { + return + } if c.Passcode.Smtp.Validate() == nil { if c.Smtp.Validate() == nil { zeroLogger.Warn().Msg("Both root smtp and passcode.smtp are set. Using smtp settings from root configuration") diff --git a/backend/docs/Config.md b/backend/docs/Config.md index ad5fdd704..fb13b9b5a 100644 --- a/backend/docs/Config.md +++ b/backend/docs/Config.md @@ -300,6 +300,18 @@ webauthn: origins: - "android:apk-key-hash:nLSu7wVTbnMOxLgC52f2faTnv..." - "https://login.example.com" +## email_delivery ## +# +# Settings needed for email delivery. +# +email_delivery: + ## enabled ## + # + # Enable or disable email delivery by hanko. Disable if you want to send the emails yourself. To send emails yourself you must subscribe to the `email.create` webhook event. + # + # Default: true + # + enabled: true ## audit_log ## # # Configures audit logging @@ -972,4 +984,10 @@ webhooks: # Email - Triggers on: change of primary email # - user.update.email.primary + ## + # + # Triggers on: an email was sent or should be sent + # + - email.send + ``` diff --git a/backend/dto/webhook/email.go b/backend/dto/webhook/email.go new file mode 100644 index 000000000..40d49237a --- /dev/null +++ b/backend/dto/webhook/email.go @@ -0,0 +1,26 @@ +package webhook + +type EmailSend struct { + Subject string `json:"subject"` // subject + BodyPlain string `json:"body_plain"` // used for string templates + Body string `json:"body,omitempty"` // used for html templates + ToEmailAddress string `json:"to_email_address"` + DeliveredByHanko bool `json:"delivered_by_hanko"` + AcceptLanguage string `json:"accept_language"` // accept_language header from http request + Type EmailType `json:"type"` // type of the email, currently only "passcode", but other could be added later + + Data interface{} `json:"data"` +} + +type PasscodeData struct { + ServiceName string `json:"service_name"` + OtpCode string `json:"otp_code"` + TTL int `json:"ttl"` + ValidUntil int64 `json:"valid_until"` // UnixTimestamp +} + +type EmailType string + +var ( + EmailTypePasscode EmailType = "passcode" +) diff --git a/backend/handler/email.go b/backend/handler/email.go index 76a8bbd81..10d72d3e4 100644 --- a/backend/handler/email.go +++ b/backend/handler/email.go @@ -144,7 +144,7 @@ func (h *EmailHandler) Create(c echo.Context) error { var evt events.Event if len(user.Emails) >= 1 { - evt = events.EmailCreate + evt = events.UserEmailCreate } else { evt = events.UserCreate } @@ -212,7 +212,7 @@ func (h *EmailHandler) SetPrimaryEmail(c echo.Context) error { return fmt.Errorf("failed to create audit log: %w", err) } - utils.NotifyUserChange(c, tx, h.persister, events.EmailPrimary, userId) + utils.NotifyUserChange(c, tx, h.persister, events.UserEmailPrimary, userId) return c.NoContent(http.StatusNoContent) }) @@ -256,7 +256,7 @@ func (h *EmailHandler) Delete(c echo.Context) error { return fmt.Errorf("failed to create audit log: %w", err) } - utils.NotifyUserChange(c, tx, h.persister, events.EmailDelete, userId) + utils.NotifyUserChange(c, tx, h.persister, events.UserEmailDelete, userId) return c.NoContent(http.StatusNoContent) }) diff --git a/backend/handler/email_admin.go b/backend/handler/email_admin.go index 9b1c92462..355df36ed 100644 --- a/backend/handler/email_admin.go +++ b/backend/handler/email_admin.go @@ -150,7 +150,7 @@ func (h *emailAdminHandler) Create(ctx echo.Context) error { err = h.persister.GetPrimaryEmailPersisterWithConnection(tx).Create(*primaryEmail) } - utils.NotifyUserChange(ctx, tx, h.persister, events.EmailCreate, userId) + utils.NotifyUserChange(ctx, tx, h.persister, events.UserEmailCreate, userId) return ctx.JSON(http.StatusCreated, admin.FromEmailModel(email)) }) @@ -229,7 +229,7 @@ func (h *emailAdminHandler) Delete(ctx echo.Context) error { return fmt.Errorf("failed to delete email from db: %w", err) } - utils.NotifyUserChange(ctx, tx, h.persister, events.EmailDelete, userId) + utils.NotifyUserChange(ctx, tx, h.persister, events.UserEmailDelete, userId) return ctx.NoContent(http.StatusNoContent) }) @@ -275,7 +275,7 @@ func (h *emailAdminHandler) SetPrimaryEmail(ctx echo.Context) error { return err } - utils.NotifyUserChange(ctx, tx, h.persister, events.EmailPrimary, userId) + utils.NotifyUserChange(ctx, tx, h.persister, events.UserEmailPrimary, userId) return ctx.NoContent(http.StatusNoContent) }) diff --git a/backend/handler/passcode.go b/backend/handler/passcode.go index 8ac0d5180..d968f8b88 100644 --- a/backend/handler/passcode.go +++ b/backend/handler/passcode.go @@ -7,11 +7,13 @@ import ( "github.com/gofrs/uuid" "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/v2/jwt" + zeroLogger "github.com/rs/zerolog/log" "github.com/sethvargo/go-limiter" "github.com/teamhanko/hanko/backend/audit_log" "github.com/teamhanko/hanko/backend/config" "github.com/teamhanko/hanko/backend/crypto" "github.com/teamhanko/hanko/backend/dto" + "github.com/teamhanko/hanko/backend/dto/webhook" "github.com/teamhanko/hanko/backend/mail" "github.com/teamhanko/hanko/backend/persistence" "github.com/teamhanko/hanko/backend/persistence/models" @@ -189,22 +191,53 @@ func (h *PasscodeHandler) Init(c echo.Context) error { } lang := c.Request().Header.Get("Accept-Language") - str, err := h.renderer.Render("loginTextMail", lang, data) + subject := h.renderer.Translate(lang, "email_subject_login", data) + bodyPlain, err := h.renderer.Render("loginTextMail", lang, data) if err != nil { return fmt.Errorf("failed to render email template: %w", err) } - message := gomail.NewMessage() - message.SetAddressHeader("To", email.Address, "") - message.SetAddressHeader("From", h.emailConfig.FromAddress, h.emailConfig.FromName) + webhookData := webhook.EmailSend{ + Subject: subject, + BodyPlain: bodyPlain, + ToEmailAddress: email.Address, + DeliveredByHanko: true, + AcceptLanguage: lang, + Type: webhook.EmailTypePasscode, + Data: webhook.PasscodeData{ + ServiceName: h.cfg.Service.Name, + OtpCode: passcode, + TTL: h.TTL, + ValidUntil: passcodeModel.CreatedAt.Add(time.Duration(h.TTL) * time.Second).UTC().Unix(), + }, + } - message.SetHeader("Subject", h.renderer.Translate(lang, "email_subject_login", data)) + if h.cfg.EmailDelivery.Enabled { + message := gomail.NewMessage() + message.SetAddressHeader("To", email.Address, "") + message.SetAddressHeader("From", h.emailConfig.FromAddress, h.emailConfig.FromName) - message.SetBody("text/plain", str) + message.SetHeader("Subject", subject) - err = h.mailer.Send(message) - if err != nil { - return fmt.Errorf("failed to send passcode: %w", err) + message.SetBody("text/plain", bodyPlain) + + err = h.mailer.Send(message) + if err != nil { + return fmt.Errorf("failed to send passcode: %w", err) + } + + err = utils.TriggerWebhooks(c, events.EmailSend, webhookData) + + if err != nil { + zeroLogger.Warn().Err(err).Msg("failed to trigger webhook") + } + } else { + webhookData.DeliveredByHanko = false + err = utils.TriggerWebhooks(c, events.EmailSend, webhookData) + + if err != nil { + return fmt.Errorf(fmt.Sprintf("failed to trigger webhook: %s", err)) + } } err = h.auditLogger.Create(c, models.AuditLogPasscodeLoginInitSucceeded, user, nil) @@ -326,7 +359,7 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { } wasUnverified := false - hasEmails := len(user.Emails) >= 1 // check if we need to trigger a UserCreate webhook or a EmailCreate one + hasEmails := len(user.Emails) >= 1 // check if we need to trigger a UserCreate webhook or a UserEmailCreate one if !passcode.Email.Verified { wasUnverified = true @@ -394,7 +427,7 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { var evt events.Event if hasEmails { - evt = events.EmailCreate + evt = events.UserEmailCreate } else { evt = events.UserCreate } diff --git a/backend/handler/public_router.go b/backend/handler/public_router.go index 5834b41e4..ed750d030 100644 --- a/backend/handler/public_router.go +++ b/backend/handler/public_router.go @@ -143,9 +143,9 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister, promet webauthnCredentials.DELETE("/:id", webauthnHandler.DeleteCredential) passcode := g.Group("/passcode") - passcodeLogin := passcode.Group("/login") + passcodeLogin := passcode.Group("/login", webhookMiddlware) passcodeLogin.POST("/initialize", passcodeHandler.Init) - passcodeLogin.POST("/finalize", passcodeHandler.Finish, webhookMiddlware) + passcodeLogin.POST("/finalize", passcodeHandler.Finish) email := g.Group("/emails", sessionMiddleware, webhookMiddlware) email.GET("", emailHandler.List) diff --git a/backend/json_schema/hanko.config.json b/backend/json_schema/hanko.config.json index fa82843dd..153e8d22f 100644 --- a/backend/json_schema/hanko.config.json +++ b/backend/json_schema/hanko.config.json @@ -133,6 +133,9 @@ "smtp": { "$ref": "#/$defs/SMTP" }, + "email_delivery": { + "$ref": "#/$defs/EmailDelivery" + }, "passcode": { "$ref": "#/$defs/Passcode" }, @@ -302,6 +305,19 @@ "additionalProperties": false, "type": "object" }, + "EmailDelivery": { + "properties": { + "enabled": { + "type": "boolean", + "default": true + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "enabled" + ] + }, "Emails": { "properties": { "require_verification": { diff --git a/backend/rate_limiter/rate_limiter.go b/backend/rate_limiter/rate_limiter.go index e92adee2c..bdef62034 100644 --- a/backend/rate_limiter/rate_limiter.go +++ b/backend/rate_limiter/rate_limiter.go @@ -53,7 +53,6 @@ func Limit(store limiter.Store, userId uuid.UUID, c echo.Context) error { } resetTime := int(math.Floor(time.Unix(0, int64(reset)).UTC().Sub(time.Now().UTC()).Seconds())) - log.Println(resetTime) // Set headers (we do this regardless of whether the request is permitted). c.Response().Header().Set(httplimit.HeaderRateLimitLimit, strconv.FormatUint(limit, 10)) diff --git a/backend/test/config.go b/backend/test/config.go index 6f0567ec1..a5fbfe154 100644 --- a/backend/test/config.go +++ b/backend/test/config.go @@ -20,6 +20,9 @@ var DefaultConfig = config.Config{ Host: "localhost", Port: "2500", }, + EmailDelivery: config.EmailDelivery{ + Enabled: true, + }, Passcode: config.Passcode{ Email: config.Email{ FromAddress: "test@hanko.io", diff --git a/backend/thirdparty/linking.go b/backend/thirdparty/linking.go index 7defb951d..e9270fe53 100644 --- a/backend/thirdparty/linking.go +++ b/backend/thirdparty/linking.go @@ -128,7 +128,7 @@ func signIn(tx *pop.Connection, cfg *config.Config, p persistence.Persister, use } identity.EmailID = email.ID - webhookEvent = events.EmailCreate + webhookEvent = events.UserEmailCreate } } diff --git a/backend/webhooks/events/events.go b/backend/webhooks/events/events.go index b277117ca..d6a0d6490 100644 --- a/backend/webhooks/events/events.go +++ b/backend/webhooks/events/events.go @@ -5,14 +5,16 @@ import "github.com/teamhanko/hanko/backend/persistence/models" type Event string const ( - User Event = "user" - UserCreate Event = "user.create" - UserUpdate Event = "user.update" - UserDelete Event = "user.delete" - Email Event = "user.update.email" - EmailCreate Event = "user.update.email.create" - EmailPrimary Event = "user.update.email.primary" - EmailDelete Event = "user.update.email.delete" + User Event = "user" + UserCreate Event = "user.create" + UserUpdate Event = "user.update" + UserDelete Event = "user.delete" + UserEmail Event = "user.update.email" + UserEmailCreate Event = "user.update.email.create" + UserEmailPrimary Event = "user.update.email.primary" + UserEmailDelete Event = "user.update.email.delete" + + EmailSend Event = "email.send" ) func StringIsValidEvent(value string) bool { @@ -23,7 +25,7 @@ func StringIsValidEvent(value string) bool { func IsValidEvent(evt Event) bool { var isValid bool switch evt { - case User, UserCreate, UserUpdate, UserDelete, Email, EmailCreate, EmailPrimary, EmailDelete: + case User, UserCreate, UserUpdate, UserDelete, UserEmail, UserEmailCreate, UserEmailPrimary, UserEmailDelete, EmailSend: isValid = true default: isValid = false diff --git a/backend/webhooks/webhook_test.go b/backend/webhooks/webhook_test.go index 8e984b47b..c47916120 100644 --- a/backend/webhooks/webhook_test.go +++ b/backend/webhooks/webhook_test.go @@ -17,7 +17,7 @@ func TestBaseWebhook_HasEvent(t *testing.T) { Events: events.Events{events.UserUpdate}, } - require.True(t, baseHook.HasEvent(events.EmailCreate)) + require.True(t, baseHook.HasEvent(events.UserEmailCreate)) } func TestWebhooks_HasEvent_WithMultipleEvents(t *testing.T) { @@ -37,7 +37,7 @@ func TestWebhooks_HasSubEvent_WithMultipleEvents(t *testing.T) { Events: events.Events{events.UserCreate, events.UserUpdate}, } - require.True(t, baseHook.HasEvent(events.EmailCreate)) + require.True(t, baseHook.HasEvent(events.UserEmailCreate)) } func TestBaseWebhook_HasSubEvent(t *testing.T) {