[FEAT] disable email delivery (#1419)

* feat: add config to disable email delivery

* chore: update config schema

* docs: add new config parameter

* test: fix test

* fix: rename email webhook event

* docs: Update backend/docs/Config.md

Co-authored-by: Lennart Fleischmann <67686424+lfleischmann@users.noreply.github.com>

---------

Co-authored-by: Lennart Fleischmann <67686424+lfleischmann@users.noreply.github.com>
This commit is contained in:
Frederic Jahn
2024-04-18 15:15:02 +02:00
committed by GitHub
parent 7276db13bb
commit def7ad37a0
13 changed files with 162 additions and 52 deletions

View File

@ -23,6 +23,7 @@ type Config struct {
Server Server `yaml:"server" json:"server,omitempty" koanf:"server"` Server Server `yaml:"server" json:"server,omitempty" koanf:"server"`
Webauthn WebauthnSettings `yaml:"webauthn" json:"webauthn,omitempty" koanf:"webauthn"` Webauthn WebauthnSettings `yaml:"webauthn" json:"webauthn,omitempty" koanf:"webauthn"`
Smtp SMTP `yaml:"smtp" json:"smtp,omitempty" koanf:"smtp"` 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"` Passcode Passcode `yaml:"passcode" json:"passcode" koanf:"passcode"`
Password Password `yaml:"password" json:"password,omitempty" koanf:"password"` Password Password `yaml:"password" json:"password,omitempty" koanf:"password"`
Database Database `yaml:"database" json:"database" koanf:"database"` Database Database `yaml:"database" json:"database" koanf:"database"`
@ -118,6 +119,9 @@ func DefaultConfig() *Config {
Smtp: SMTP{ Smtp: SMTP{
Port: "465", Port: "465",
}, },
EmailDelivery: EmailDelivery{
Enabled: true,
},
Passcode: Passcode{ Passcode: Passcode{
TTL: 300, TTL: 300,
Email: Email{ Email: Email{
@ -203,10 +207,12 @@ func (c *Config) Validate() error {
if err != nil { if err != nil {
return fmt.Errorf("failed to validate webauthn settings: %w", err) return fmt.Errorf("failed to validate webauthn settings: %w", err)
} }
if c.EmailDelivery.Enabled {
err = c.Smtp.Validate() err = c.Smtp.Validate()
if err != nil { if err != nil {
return fmt.Errorf("failed to validate smtp settings: %w", err) return fmt.Errorf("failed to validate smtp settings: %w", err)
} }
}
err = c.Passcode.Validate() err = c.Passcode.Validate()
if err != nil { if err != nil {
return fmt.Errorf("failed to validate passcode settings: %w", err) return fmt.Errorf("failed to validate passcode settings: %w", err)
@ -382,6 +388,10 @@ func (s *SMTP) Validate() error {
return nil return nil
} }
type EmailDelivery struct {
Enabled bool `yaml:"enabled" json:"enabled" koanf:"enabled" jsonschema:"default=true"`
}
type Email struct { type Email struct {
FromAddress string `yaml:"from_address" json:"from_address,omitempty" koanf:"from_address" split_words:"true" jsonschema:"default=passcode@hanko.io"` 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"` FromName string `yaml:"from_name" json:"from_name,omitempty" koanf:"from_name" split_words:"true" jsonschema:"default=Hanko"`
@ -688,6 +698,9 @@ func (c *Config) PostProcess() error {
} }
func (c *Config) arrangeSmtpSettings() { func (c *Config) arrangeSmtpSettings() {
if !c.EmailDelivery.Enabled {
return
}
if c.Passcode.Smtp.Validate() == nil { if c.Passcode.Smtp.Validate() == nil {
if c.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") zeroLogger.Warn().Msg("Both root smtp and passcode.smtp are set. Using smtp settings from root configuration")

View File

@ -300,6 +300,18 @@ webauthn:
origins: origins:
- "android:apk-key-hash:nLSu7wVTbnMOxLgC52f2faTnv..." - "android:apk-key-hash:nLSu7wVTbnMOxLgC52f2faTnv..."
- "https://login.example.com" - "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 ## ## audit_log ##
# #
# Configures audit logging # Configures audit logging
@ -1011,4 +1023,10 @@ webhooks:
# Email - Triggers on: change of primary email # Email - Triggers on: change of primary email
# #
- user.update.email.primary - user.update.email.primary
##
#
# Triggers on: an email was sent or should be sent
#
- email.send
``` ```

View File

@ -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"
)

View File

@ -144,7 +144,7 @@ func (h *EmailHandler) Create(c echo.Context) error {
var evt events.Event var evt events.Event
if len(user.Emails) >= 1 { if len(user.Emails) >= 1 {
evt = events.EmailCreate evt = events.UserEmailCreate
} else { } else {
evt = events.UserCreate 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) 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) 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) 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) return c.NoContent(http.StatusNoContent)
}) })

View File

@ -150,7 +150,7 @@ func (h *emailAdminHandler) Create(ctx echo.Context) error {
err = h.persister.GetPrimaryEmailPersisterWithConnection(tx).Create(*primaryEmail) 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)) 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) 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) return ctx.NoContent(http.StatusNoContent)
}) })
@ -275,7 +275,7 @@ func (h *emailAdminHandler) SetPrimaryEmail(ctx echo.Context) error {
return err 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) return ctx.NoContent(http.StatusNoContent)
}) })

View File

@ -7,11 +7,13 @@ import (
"github.com/gofrs/uuid" "github.com/gofrs/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/v2/jwt" "github.com/lestrrat-go/jwx/v2/jwt"
zeroLogger "github.com/rs/zerolog/log"
"github.com/sethvargo/go-limiter" "github.com/sethvargo/go-limiter"
"github.com/teamhanko/hanko/backend/audit_log" "github.com/teamhanko/hanko/backend/audit_log"
"github.com/teamhanko/hanko/backend/config" "github.com/teamhanko/hanko/backend/config"
"github.com/teamhanko/hanko/backend/crypto" "github.com/teamhanko/hanko/backend/crypto"
"github.com/teamhanko/hanko/backend/dto" "github.com/teamhanko/hanko/backend/dto"
"github.com/teamhanko/hanko/backend/dto/webhook"
"github.com/teamhanko/hanko/backend/mail" "github.com/teamhanko/hanko/backend/mail"
"github.com/teamhanko/hanko/backend/persistence" "github.com/teamhanko/hanko/backend/persistence"
"github.com/teamhanko/hanko/backend/persistence/models" "github.com/teamhanko/hanko/backend/persistence/models"
@ -189,24 +191,55 @@ func (h *PasscodeHandler) Init(c echo.Context) error {
} }
lang := c.Request().Header.Get("Accept-Language") 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 { if err != nil {
return fmt.Errorf("failed to render email template: %w", err) return fmt.Errorf("failed to render email template: %w", err)
} }
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(),
},
}
if h.cfg.EmailDelivery.Enabled {
message := gomail.NewMessage() message := gomail.NewMessage()
message.SetAddressHeader("To", email.Address, "") message.SetAddressHeader("To", email.Address, "")
message.SetAddressHeader("From", h.emailConfig.FromAddress, h.emailConfig.FromName) message.SetAddressHeader("From", h.emailConfig.FromAddress, h.emailConfig.FromName)
message.SetHeader("Subject", h.renderer.Translate(lang, "email_subject_login", data)) message.SetHeader("Subject", subject)
message.SetBody("text/plain", str) message.SetBody("text/plain", bodyPlain)
err = h.mailer.Send(message) err = h.mailer.Send(message)
if err != nil { if err != nil {
return fmt.Errorf("failed to send passcode: %w", err) 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) err = h.auditLogger.Create(c, models.AuditLogPasscodeLoginInitSucceeded, user, nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to create audit log: %w", err) return fmt.Errorf("failed to create audit log: %w", err)
@ -326,7 +359,7 @@ func (h *PasscodeHandler) Finish(c echo.Context) error {
} }
wasUnverified := false 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 { if !passcode.Email.Verified {
wasUnverified = true wasUnverified = true
@ -394,7 +427,7 @@ func (h *PasscodeHandler) Finish(c echo.Context) error {
var evt events.Event var evt events.Event
if hasEmails { if hasEmails {
evt = events.EmailCreate evt = events.UserEmailCreate
} else { } else {
evt = events.UserCreate evt = events.UserCreate
} }

View File

@ -143,9 +143,9 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister, promet
webauthnCredentials.DELETE("/:id", webauthnHandler.DeleteCredential) webauthnCredentials.DELETE("/:id", webauthnHandler.DeleteCredential)
passcode := g.Group("/passcode") passcode := g.Group("/passcode")
passcodeLogin := passcode.Group("/login") passcodeLogin := passcode.Group("/login", webhookMiddlware)
passcodeLogin.POST("/initialize", passcodeHandler.Init) passcodeLogin.POST("/initialize", passcodeHandler.Init)
passcodeLogin.POST("/finalize", passcodeHandler.Finish, webhookMiddlware) passcodeLogin.POST("/finalize", passcodeHandler.Finish)
email := g.Group("/emails", sessionMiddleware, webhookMiddlware) email := g.Group("/emails", sessionMiddleware, webhookMiddlware)
email.GET("", emailHandler.List) email.GET("", emailHandler.List)

View File

@ -133,6 +133,9 @@
"smtp": { "smtp": {
"$ref": "#/$defs/SMTP" "$ref": "#/$defs/SMTP"
}, },
"email_delivery": {
"$ref": "#/$defs/EmailDelivery"
},
"passcode": { "passcode": {
"$ref": "#/$defs/Passcode" "$ref": "#/$defs/Passcode"
}, },
@ -302,6 +305,19 @@
"additionalProperties": false, "additionalProperties": false,
"type": "object" "type": "object"
}, },
"EmailDelivery": {
"properties": {
"enabled": {
"type": "boolean",
"default": true
}
},
"additionalProperties": false,
"type": "object",
"required": [
"enabled"
]
},
"Emails": { "Emails": {
"properties": { "properties": {
"require_verification": { "require_verification": {

View File

@ -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())) 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). // Set headers (we do this regardless of whether the request is permitted).
c.Response().Header().Set(httplimit.HeaderRateLimitLimit, strconv.FormatUint(limit, 10)) c.Response().Header().Set(httplimit.HeaderRateLimitLimit, strconv.FormatUint(limit, 10))

View File

@ -20,6 +20,9 @@ var DefaultConfig = config.Config{
Host: "localhost", Host: "localhost",
Port: "2500", Port: "2500",
}, },
EmailDelivery: config.EmailDelivery{
Enabled: true,
},
Passcode: config.Passcode{ Passcode: config.Passcode{
Email: config.Email{ Email: config.Email{
FromAddress: "test@hanko.io", FromAddress: "test@hanko.io",

View File

@ -128,7 +128,7 @@ func signIn(tx *pop.Connection, cfg *config.Config, p persistence.Persister, use
} }
identity.EmailID = email.ID identity.EmailID = email.ID
webhookEvent = events.EmailCreate webhookEvent = events.UserEmailCreate
} }
} }

View File

@ -9,10 +9,12 @@ const (
UserCreate Event = "user.create" UserCreate Event = "user.create"
UserUpdate Event = "user.update" UserUpdate Event = "user.update"
UserDelete Event = "user.delete" UserDelete Event = "user.delete"
Email Event = "user.update.email" UserEmail Event = "user.update.email"
EmailCreate Event = "user.update.email.create" UserEmailCreate Event = "user.update.email.create"
EmailPrimary Event = "user.update.email.primary" UserEmailPrimary Event = "user.update.email.primary"
EmailDelete Event = "user.update.email.delete" UserEmailDelete Event = "user.update.email.delete"
EmailSend Event = "email.send"
) )
func StringIsValidEvent(value string) bool { func StringIsValidEvent(value string) bool {
@ -23,7 +25,7 @@ func StringIsValidEvent(value string) bool {
func IsValidEvent(evt Event) bool { func IsValidEvent(evt Event) bool {
var isValid bool var isValid bool
switch evt { switch evt {
case User, UserCreate, UserUpdate, UserDelete, Email, EmailCreate, EmailPrimary, EmailDelete: case User, UserCreate, UserUpdate, UserDelete, UserEmail, UserEmailCreate, UserEmailPrimary, UserEmailDelete, EmailSend:
isValid = true isValid = true
default: default:
isValid = false isValid = false

View File

@ -17,7 +17,7 @@ func TestBaseWebhook_HasEvent(t *testing.T) {
Events: events.Events{events.UserUpdate}, 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) { 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}, 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) { func TestBaseWebhook_HasSubEvent(t *testing.T) {