Skip to content

Commit

Permalink
[FEAT] disable email delivery (teamhanko#1419)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

---------

Co-authored-by: Lennart Fleischmann <[email protected]>
  • Loading branch information
FreddyDevelop and lfleischmann authored Apr 18, 2024
1 parent 7276db1 commit def7ad3
Show file tree
Hide file tree
Showing 13 changed files with 162 additions and 52 deletions.
53 changes: 33 additions & 20 deletions backend/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -118,6 +119,9 @@ func DefaultConfig() *Config {
Smtp: SMTP{
Port: "465",
},
EmailDelivery: EmailDelivery{
Enabled: true,
},
Passcode: Passcode{
TTL: 300,
Email: Email{
Expand Down Expand Up @@ -203,9 +207,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 {
Expand Down Expand Up @@ -382,6 +388,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:"[email protected]"`
FromName string `yaml:"from_name" json:"from_name,omitempty" koanf:"from_name" split_words:"true" jsonschema:"default=Hanko"`
Expand Down Expand Up @@ -688,6 +698,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")
Expand Down
18 changes: 18 additions & 0 deletions backend/docs/Config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1011,4 +1023,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

```
26 changes: 26 additions & 0 deletions backend/dto/webhook/email.go
Original file line number Diff line number Diff line change
@@ -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"
)
6 changes: 3 additions & 3 deletions backend/handler/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
})
Expand Down Expand Up @@ -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)
})
Expand Down
6 changes: 3 additions & 3 deletions backend/handler/email_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
Expand Down Expand Up @@ -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)
})
Expand Down Expand Up @@ -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)
})
Expand Down
55 changes: 44 additions & 11 deletions backend/handler/passcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions backend/handler/public_router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions backend/json_schema/hanko.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@
"smtp": {
"$ref": "#/$defs/SMTP"
},
"email_delivery": {
"$ref": "#/$defs/EmailDelivery"
},
"passcode": {
"$ref": "#/$defs/Passcode"
},
Expand Down Expand Up @@ -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": {
Expand Down
1 change: 0 additions & 1 deletion backend/rate_limiter/rate_limiter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
3 changes: 3 additions & 0 deletions backend/test/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ var DefaultConfig = config.Config{
Host: "localhost",
Port: "2500",
},
EmailDelivery: config.EmailDelivery{
Enabled: true,
},
Passcode: config.Passcode{
Email: config.Email{
FromAddress: "[email protected]",
Expand Down
2 changes: 1 addition & 1 deletion backend/thirdparty/linking.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
20 changes: 11 additions & 9 deletions backend/webhooks/events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading

0 comments on commit def7ad3

Please sign in to comment.