From 78b1cfccac4457b37f8727c96b5910204e133bd6 Mon Sep 17 00:00:00 2001 From: Weston Haught Date: Mon, 12 Oct 2020 20:19:30 -0700 Subject: [PATCH 01/31] starting email template --- cmd/server/assets/realmadmin/_form_email.html | 89 +++++++++++++++++++ cmd/server/assets/realmadmin/edit.html | 6 ++ 2 files changed, 95 insertions(+) create mode 100644 cmd/server/assets/realmadmin/_form_email.html diff --git a/cmd/server/assets/realmadmin/_form_email.html b/cmd/server/assets/realmadmin/_form_email.html new file mode 100644 index 000000000..02bab4456 --- /dev/null +++ b/cmd/server/assets/realmadmin/_form_email.html @@ -0,0 +1,89 @@ +{{define "realmadmin/_form_email"}} + +{{$realm := .realm}} +{{$smsConfig := .smsConfig}} +{{$countries := .countries}} + +

+ These are the settings for configuring an email provider. The verification server + may use this email account to send invitations, password resets, and account-verifications + for the realm. +

+ +
+ {{ .csrfField }} + + + {{if $realm.CanUseSystemSMSConfig}} + {{if $realm.UseSystemSMSConfig}} +
+ Warning! You are currently using the system-provided + SMS configuration. Specifying values below will override the system + configuration to use your supplied credentials. +
+ {{end}} + +
+ + +
+ {{end}} + +
+
+ + + {{template "errorable" $smsConfig.ErrorsFor "emailAccount"}} + + This is the SMTP email account eg. noreply@myrealm.com + +
+ +
+
+ + +
+ + + +
+
+ {{template "errorable" $smsConfig.ErrorsFor "twilioAuthToken"}} + + This is the password for your SMTP email. + +
+ +
+ + + {{template "errorable" $smsConfig.ErrorsFor "smtpHost"}} + + SMTP host is the hostname for the SMTP server. + +
+ +
+ + + {{template "errorable" $smsConfig.ErrorsFor "smtpPort"}} + + SMTP port is the port number to connect to. + +
+
+ +
+ +
+
+ +{{end}} diff --git a/cmd/server/assets/realmadmin/edit.html b/cmd/server/assets/realmadmin/edit.html index 0f7980b1c..637031b1c 100644 --- a/cmd/server/assets/realmadmin/edit.html +++ b/cmd/server/assets/realmadmin/edit.html @@ -39,6 +39,9 @@

Realm settings

+ @@ -64,6 +67,9 @@

Realm settings

{{template "realmadmin/_form_sms" .}}
+
+ {{template "realmadmin/_form_email" .}} +
{{template "realmadmin/_form_security" .}}
From 6cf444d315c05ee93f6d0fd8faa12069b7cc52cd Mon Sep 17 00:00:00 2001 From: Weston Haught Date: Tue, 13 Oct 2020 19:46:23 -0700 Subject: [PATCH 02/31] first thing --- cmd/server/assets/realmadmin/_form_email.html | 50 +++--- pkg/database/database.go | 9 ++ pkg/database/email_config.go | 115 +++++++++++++ pkg/database/email_config_test.go | 152 ++++++++++++++++++ 4 files changed, 301 insertions(+), 25 deletions(-) create mode 100644 pkg/database/email_config.go create mode 100644 pkg/database/email_config_test.go diff --git a/cmd/server/assets/realmadmin/_form_email.html b/cmd/server/assets/realmadmin/_form_email.html index 02bab4456..d1b285645 100644 --- a/cmd/server/assets/realmadmin/_form_email.html +++ b/cmd/server/assets/realmadmin/_form_email.html @@ -1,11 +1,11 @@ {{define "realmadmin/_form_email"}} {{$realm := .realm}} -{{$smsConfig := .smsConfig}} +{{$emailConfig := .smtpConfig}} {{$countries := .countries}}

- These are the settings for configuring an email provider. The verification server + These are the settings for configuring an SMTP email provider. The verification server may use this email account to send invitations, password resets, and account-verifications for the realm.

@@ -14,30 +14,30 @@ {{ .csrfField }} - {{if $realm.CanUseSystemSMSConfig}} - {{if $realm.UseSystemSMSConfig}} + {{if $realm.CanUseSystemSMTPConfig}} + {{if $realm.UseSystemSMTPConfig}}
Warning! You are currently using the system-provided - SMS configuration. Specifying values below will override the system + SMTP email configuration. Specifying values below will override the system configuration to use your supplied credentials.
{{end}}
- -
{{end}} -
+
- - - {{template "errorable" $smsConfig.ErrorsFor "emailAccount"}} + + + {{template "errorable" $emailConfig.ErrorsFor "smtpAccount"}} This is the SMTP email account eg. noreply@myrealm.com @@ -45,8 +45,8 @@
- +
- {{template "errorable" $smsConfig.ErrorsFor "twilioAuthToken"}} + {{template "errorable" $emailConfig.ErrorsFor "smtpPassword"}} This is the password for your SMTP email.
- + - {{template "errorable" $smsConfig.ErrorsFor "smtpHost"}} + {{template "errorable" $emailConfig.ErrorsFor "smtpHost"}} SMTP host is the hostname for the SMTP server.
- - - {{template "errorable" $smsConfig.ErrorsFor "smtpPort"}} + + + {{template "errorable" $emailConfig.ErrorsFor "smtpPort"}} SMTP port is the port number to connect to. @@ -82,7 +82,7 @@
- +
diff --git a/pkg/database/database.go b/pkg/database/database.go index a76145ec4..f1eab568f 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -196,6 +196,15 @@ func (db *Database) OpenWithCacher(ctx context.Context, cacher cache.Cacher) err rawDB.Callback().Query().After("gorm:after_query").Register("sms_configs:decrypt", callbackKMSDecrypt(ctx, db.keyManager, c.EncryptionKey, "sms_configs", "TwilioAuthToken")) + // Email configs + rawDB.Callback().Create().Before("gorm:create").Register("email_configs:encrypt", callbackKMSEncrypt(ctx, db.keyManager, c.EncryptionKey, "email_configs", "SMTPPassword")) + rawDB.Callback().Create().After("gorm:create").Register("email_configs:decrypt", callbackKMSDecrypt(ctx, db.keyManager, c.EncryptionKey, "email_configs", "SMTPPassword")) + + rawDB.Callback().Update().Before("gorm:update").Register("email_configs:encrypt", callbackKMSEncrypt(ctx, db.keyManager, c.EncryptionKey, "email_configs", "SMTPPassword")) + rawDB.Callback().Update().After("gorm:update").Register("email_configs:decrypt", callbackKMSDecrypt(ctx, db.keyManager, c.EncryptionKey, "email_configs", "SMTPPassword")) + + rawDB.Callback().Query().After("gorm:after_query").Register("email_configs:decrypt", callbackKMSDecrypt(ctx, db.keyManager, c.EncryptionKey, "email_configs", "SMTPPassword")) + // Verification codes rawDB.Callback().Create().Before("gorm:create").Register("verification_codes:hmac_code", callbackHMAC(ctx, db.GenerateVerificationCodeHMAC, "verification_codes", "code")) rawDB.Callback().Create().Before("gorm:create").Register("verification_codes:hmac_long_code", callbackHMAC(ctx, db.GenerateVerificationCodeHMAC, "verification_codes", "long_code")) diff --git a/pkg/database/email_config.go b/pkg/database/email_config.go new file mode 100644 index 000000000..5c659226c --- /dev/null +++ b/pkg/database/email_config.go @@ -0,0 +1,115 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package database + +import ( + "fmt" + "strings" + + "github.com/google/exposure-notifications-verification-server/pkg/email" + "github.com/jinzhu/gorm" +) + +// EmailConfig represents and email configuration. +type EmailConfig struct { + gorm.Model + Errorable + + // email Config belongs to exactly one realm. + RealmID uint + + // ProviderType is the email provider type - it's used to determine the + // underlying configuration. + ProviderType email.ProviderType `gorm:"type:varchar(100)"` + + SMTPAccount string `gorm:"type:varchar(250)"` + SMTPHost string `gorm:"type:varchar(250)"` + SMTPPort string `gorm:"type:varchar(250)"` + + // TwilioAuthToken is encrypted/decrypted automatically by callbacks. The + // cache fields exist as optimizations. + SMTPPassword string `gorm:"type:varchar(250)"` + SMTPPasswordPlaintextCache string `gorm:"-"` + SMTPPasswordCiphertextCache string `gorm:"-"` + + // IsSystem determines if this is a system-level email configuration. There can + // only be one system-level email configuration. + IsSystem bool `gorm:"type:bool; not null; default:false;"` +} + +func (s *EmailConfig) BeforeSave(tx *gorm.DB) error { + // Twilio config is all or nothing + if (s.TwilioAccountSid != "" || s.TwilioAuthToken != "" || s.TwilioFromNumber != "") && + (s.TwilioAccountSid == "" || s.TwilioAuthToken == "" || s.TwilioFromNumber == "") { + s.AddError("twilioAccountSid", "all must be specified or all must be blank") + s.AddError("twilioAuthToken", "all must be specified or all must be blank") + s.AddError("twilioFromNumber", "all must be specified or all must be blank") + } + + if len(s.Errors()) > 0 { + return fmt.Errorf("email config validation failed: %s", strings.Join(s.ErrorMessages(), ", ")) + } + return nil +} + +// SystemEmailConfig returns the system email config, if one exists +func (db *Database) SystemEmailConfig() (*EmailConfig, error) { + var emailConfig emailConfig + if err := db.db. + Model(&EmailConfig{}). + Where("is_system IS TRUE"). + First(&emailConfig). + Error; err != nil { + return nil, err + } + return &emailConfig, nil +} + +// SaveEmailConfig creates or updates an email configuration record. +func (db *Database) SaveEmailConfig(s *EmailConfig) error { + if s.TwilioAccountSid == "" && s.TwilioAuthToken == "" && s.TwilioFromNumber == "" { + if db.db.NewRecord(s) { + // The fields are all blank, do not create the record. + return nil + } + + if s.IsSystem { + // We're about to delete the system email config, revoke everyone's + // permissions to use it. You would think there'd be a way to do this with + // gorm natively. You'd even find an example in the documentation that led + // you to believe that gorm does this. + // + // Narrator: gorm does not do this. + if err := db.db. + Exec(`UPDATE realms SET can_use_system_email_config = FALSE, use_system_email_config = FALSE`). + Error; err != nil { + return err + } + } + + // The fields were blank, delete the email config. + return db.db.Unscoped().Delete(s).Error + } + + if db.db.NewRecord(s) { + return db.db.Create(s).Error + } + return db.db.Save(s).Error +} + +// DeleteEmailConfig removes an email configuration record. +func (db *Database) DeleteEmailConfig(s *EmailConfig) error { + return db.db.Unscoped().Delete(s).Error +} diff --git a/pkg/database/email_config_test.go b/pkg/database/email_config_test.go new file mode 100644 index 000000000..7b737cd99 --- /dev/null +++ b/pkg/database/email_config_test.go @@ -0,0 +1,152 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package database + +import ( + "testing" + + "github.com/google/exposure-notifications-verification-server/pkg/sms" +) + +func TestSMSConfig_Lifecycle(t *testing.T) { + t.Parallel() + + db := NewTestDatabase(t) + + // Create realm + realmName := t.Name() + realm, err := db.CreateRealm(realmName) + if err != nil { + t.Fatalf("unable to cerate test realm: %v", err) + } + + // Initial config should be nil + { + got, err := realm.SMSConfig(db) + if !IsNotFound(err) { + t.Errorf("expected %#v to be %#v", err, "not found") + } + if got != nil { + t.Errorf("expected %#v to be %#v", got, nil) + } + } + + // Create SMS config on the realm + smsConfig := &SMSConfig{ + RealmID: realm.ID, + ProviderType: sms.ProviderType("TWILIO"), + TwilioAccountSid: "abc123", + TwilioAuthToken: "def123", + TwilioFromNumber: "+11234567890", + } + if err := db.SaveSMSConfig(smsConfig); err != nil { + t.Fatal(err) + } + + // Get the realm to verify SMS configs are NOT preloaded + realm, err = db.FindRealm(realm.ID) + if err != nil { + t.Fatal(err) + } + + // Load the SMS config + { + got, err := realm.SMSConfig(db) + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatalf("expected SMSConfig, got %#v", got) + } + + if got, want := got.ID, smsConfig.ID; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + if got, want := got.RealmID, smsConfig.RealmID; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + if got, want := got.ProviderType, smsConfig.ProviderType; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + if got, want := got.TwilioAccountSid, smsConfig.TwilioAccountSid; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + if got, want := got.TwilioAuthToken, smsConfig.TwilioAuthToken; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + if got, want := got.TwilioFromNumber, smsConfig.TwilioFromNumber; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + } + + // Update value + smsConfig.TwilioAuthToken = "banana123" + if err := db.SaveSMSConfig(smsConfig); err != nil { + t.Fatal(err) + } + + // Read back updated value + { + got, err := realm.SMSConfig(db) + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatalf("expected SMSConfig, got %#v", got) + } + + if got, want := got.TwilioAuthToken, "banana123"; got != want { + t.Errorf("expected %#v to be %#v", got, want) + } + } +} + +func TestSMSProvider(t *testing.T) { + t.Parallel() + + db := NewTestDatabase(t) + + realm, err := db.CreateRealm("test-sms-realm-1") + if err != nil { + t.Fatalf("realm create failed: %v", err) + } + + provider, err := realm.SMSProvider(db) + if err != nil { + t.Fatal(err) + } + if provider != nil { + t.Errorf("expected %v to be %v", provider, nil) + } + + smsConfig := &SMSConfig{ + RealmID: realm.ID, + ProviderType: sms.ProviderType("TWILIO"), + TwilioAccountSid: "abc123", + TwilioAuthToken: "my-secret-ref", + TwilioFromNumber: "+11234567890", + } + if err := db.SaveSMSConfig(smsConfig); err != nil { + t.Fatal(err) + } + + provider, err = realm.SMSProvider(db) + if err != nil { + t.Fatal(err) + } + if provider == nil { + t.Errorf("expected %v to be not nil", provider) + } +} From 3613515dd2be698ca2e363064610319aad2d87d1 Mon Sep 17 00:00:00 2001 From: Weston Haught Date: Wed, 14 Oct 2020 16:58:31 -0700 Subject: [PATCH 03/31] email settings --- internal/firebase/password_reset_email.go | 2 +- pkg/controller/controller.go | 28 +++++++ pkg/controller/user/importbatch.go | 9 ++- pkg/controller/user/reset_password.go | 17 ++++- pkg/database/email_config.go | 16 ++-- pkg/database/email_config_test.go | 74 +++++++++---------- pkg/database/realm.go | 90 +++++++++++++++++++++++ pkg/email/config.go | 16 +--- pkg/email/firebase.go | 34 --------- pkg/email/noop.go | 6 +- pkg/email/smtp.go | 49 ++---------- 11 files changed, 202 insertions(+), 139 deletions(-) delete mode 100644 pkg/email/firebase.go diff --git a/internal/firebase/password_reset_email.go b/internal/firebase/password_reset_email.go index 9573df81e..c72996ad4 100644 --- a/internal/firebase/password_reset_email.go +++ b/internal/firebase/password_reset_email.go @@ -36,7 +36,7 @@ type sendPasswordResetEmailRequest struct { // face rate-limiting on the firebase side if called too quickly. // // See: https://firebase.google.com/docs/reference/rest/auth#section-send-password-reset-email -func (c *Client) SendNewUserInvitation(ctx context.Context, email string) error { +func (c *Client) SendNewUserInvitation(ctx context.Context, email, realmName string) error { r := &sendPasswordResetEmailRequest{ RequestType: "PASSWORD_RESET", Email: email, diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 7f3d99a68..4b7fd5c3e 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -21,6 +21,7 @@ import ( "net/http" "strings" + "firebase.google.com/go/auth" "github.com/google/exposure-notifications-verification-server/pkg/api" "github.com/google/exposure-notifications-verification-server/pkg/render" @@ -125,6 +126,33 @@ func RedirectToChangePassword(w http.ResponseWriter, r *http.Request, h *render. http.Redirect(w, r, "/login/change-password", http.StatusSeeOther) } +// ComposeInviteEmail uses the renderer and auth client to generate a password reset link +// and emit an invite email +func ComposeInviteEmail(h *render.Renderer, auth *auth.Client, toEmail, fromEmail, realmName string) (string, error) { + inviteLink, err := auth.PasswordResetLink(ctx, toEmail) + if err != nil { + return err + } + + // Compose message + message, err := h.RenderEmail("email/invite", + struct { + ToEmail string + FromEmail string + InviteLink string + RealmName string + }{ + ToEmail: toEmail, + FromEmail: fromEmail, + InviteLink: inviteLink, + RealmName: realmName, + }) + if err != nil { + return nil, err + } + return message, nil +} + func prefixInList(list []string, prefix string) bool { for _, v := range list { if strings.HasPrefix(v, prefix) { diff --git a/pkg/controller/user/importbatch.go b/pkg/controller/user/importbatch.go index c26d66340..7998639c1 100644 --- a/pkg/controller/user/importbatch.go +++ b/pkg/controller/user/importbatch.go @@ -74,7 +74,14 @@ func (c *Controller) HandleImportBatch() http.Handler { continue } else if created { newUsers = append(newUsers, &batchUser) - if err := c.emailer.SendNewUserInvitation(ctx, user.Email); err != nil { + message, err := controller.ComposeInviteEmail(c.h, c.client, user.Email, currentUser.Email, realm.Name) + if err != nil { + c.logger.Warnw("failed composing invitation", "error", err) + batchErr = multierror.Append(batchErr, errors.New("send invitation failed")) + continue + } + + if err := c.emailer.SendNewUserInvitation(ctx, user.Email, realm.Name); err != nil { c.logger.Warnw("failed sending invitation", "error", err) batchErr = multierror.Append(batchErr, errors.New("send invitation failed")) continue diff --git a/pkg/controller/user/reset_password.go b/pkg/controller/user/reset_password.go index b308249a4..84e0de134 100644 --- a/pkg/controller/user/reset_password.go +++ b/pkg/controller/user/reset_password.go @@ -49,7 +49,22 @@ func (c *Controller) ensureFirebaseUserExists(ctx context.Context, user *databas } if created { - err := c.emailer.SendNewUserInvitation(ctx, user.Email) + if c.emailer == nil { + // fallback to firebase + c.config.Firebase + } + + realm := controller.RealmFromContext(ctx) + currentUser := controller.UserFromContext(ctx) + + message, err := controller.ComposeInviteEmail(c.h, c.client, user.Email, currentUser.Email, realm.Name) + if err != nil { + c.logger.Warnw("failed composing invitation", "error", err) + flash.Error("Could not send new user invitation.") + return true, err + } + + err := c.emailer.SendEmail(ctx, user.Email, message) if err != nil { c.logger.Warnw("failed sending invitation", "error", err) flash.Error("Could not send new user invitation.") diff --git a/pkg/database/email_config.go b/pkg/database/email_config.go index 5c659226c..37e7ed724 100644 --- a/pkg/database/email_config.go +++ b/pkg/database/email_config.go @@ -50,12 +50,12 @@ type EmailConfig struct { } func (s *EmailConfig) BeforeSave(tx *gorm.DB) error { - // Twilio config is all or nothing - if (s.TwilioAccountSid != "" || s.TwilioAuthToken != "" || s.TwilioFromNumber != "") && - (s.TwilioAccountSid == "" || s.TwilioAuthToken == "" || s.TwilioFromNumber == "") { - s.AddError("twilioAccountSid", "all must be specified or all must be blank") - s.AddError("twilioAuthToken", "all must be specified or all must be blank") - s.AddError("twilioFromNumber", "all must be specified or all must be blank") + // Email config is all or nothing + if (s.SMTPAccount != "" || s.SMTPPassword != "" || s.SMTPHost != "") && + (s.SMTPAccount == "" || s.SMTPPassword == "" || s.SMTPHost == "") { + s.AddError("SMTPAccount", "all must be specified or all must be blank") + s.AddError("SMTPPassword", "all must be specified or all must be blank") + s.AddError("SMTPHost", "all must be specified or all must be blank") } if len(s.Errors()) > 0 { @@ -66,7 +66,7 @@ func (s *EmailConfig) BeforeSave(tx *gorm.DB) error { // SystemEmailConfig returns the system email config, if one exists func (db *Database) SystemEmailConfig() (*EmailConfig, error) { - var emailConfig emailConfig + var emailConfig EmailConfig if err := db.db. Model(&EmailConfig{}). Where("is_system IS TRUE"). @@ -79,7 +79,7 @@ func (db *Database) SystemEmailConfig() (*EmailConfig, error) { // SaveEmailConfig creates or updates an email configuration record. func (db *Database) SaveEmailConfig(s *EmailConfig) error { - if s.TwilioAccountSid == "" && s.TwilioAuthToken == "" && s.TwilioFromNumber == "" { + if s.SMTPAccount == "" && s.SMTPPassword == "" && s.SMTPHost == "" { if db.db.NewRecord(s) { // The fields are all blank, do not create the record. return nil diff --git a/pkg/database/email_config_test.go b/pkg/database/email_config_test.go index 7b737cd99..f0c9560b6 100644 --- a/pkg/database/email_config_test.go +++ b/pkg/database/email_config_test.go @@ -17,10 +17,10 @@ package database import ( "testing" - "github.com/google/exposure-notifications-verification-server/pkg/sms" + "github.com/google/exposure-notifications-verification-server/pkg/email" ) -func TestSMSConfig_Lifecycle(t *testing.T) { +func TestEmailConfig_Lifecycle(t *testing.T) { t.Parallel() db := NewTestDatabase(t) @@ -34,7 +34,7 @@ func TestSMSConfig_Lifecycle(t *testing.T) { // Initial config should be nil { - got, err := realm.SMSConfig(db) + got, err := realm.EmailConfig(db) if !IsNotFound(err) { t.Errorf("expected %#v to be %#v", err, "not found") } @@ -43,87 +43,87 @@ func TestSMSConfig_Lifecycle(t *testing.T) { } } - // Create SMS config on the realm - smsConfig := &SMSConfig{ - RealmID: realm.ID, - ProviderType: sms.ProviderType("TWILIO"), - TwilioAccountSid: "abc123", - TwilioAuthToken: "def123", - TwilioFromNumber: "+11234567890", + // Create email config on the realm + emailConfig := &EmailConfig{ + RealmID: realm.ID, + ProviderType: email.ProviderType("TWILIO"), + SMTPAccount: "noreply@sendemails.meh", + SMTPPassword: "my-secret-ref", + SMTPHost: "smtp.sendemails.meh", } - if err := db.SaveSMSConfig(smsConfig); err != nil { + if err := db.SaveEmailConfig(emailConfig); err != nil { t.Fatal(err) } - // Get the realm to verify SMS configs are NOT preloaded + // Get the realm to verify email configs are NOT preloaded realm, err = db.FindRealm(realm.ID) if err != nil { t.Fatal(err) } - // Load the SMS config + // Load the email config { - got, err := realm.SMSConfig(db) + got, err := realm.EmailConfig(db) if err != nil { t.Fatal(err) } if got == nil { - t.Fatalf("expected SMSConfig, got %#v", got) + t.Fatalf("expected emailConfig, got %#v", got) } - if got, want := got.ID, smsConfig.ID; got != want { + if got, want := got.ID, emailConfig.ID; got != want { t.Errorf("expected %#v to be %#v", got, want) } - if got, want := got.RealmID, smsConfig.RealmID; got != want { + if got, want := got.RealmID, emailConfig.RealmID; got != want { t.Errorf("expected %#v to be %#v", got, want) } - if got, want := got.ProviderType, smsConfig.ProviderType; got != want { + if got, want := got.ProviderType, emailConfig.ProviderType; got != want { t.Errorf("expected %#v to be %#v", got, want) } - if got, want := got.TwilioAccountSid, smsConfig.TwilioAccountSid; got != want { + if got, want := got.SMTPAccount, emailConfig.SMTPAccount; got != want { t.Errorf("expected %#v to be %#v", got, want) } - if got, want := got.TwilioAuthToken, smsConfig.TwilioAuthToken; got != want { + if got, want := got.SMTPPassword, emailConfig.SMTPPassword; got != want { t.Errorf("expected %#v to be %#v", got, want) } - if got, want := got.TwilioFromNumber, smsConfig.TwilioFromNumber; got != want { + if got, want := got.SMTPHost, emailConfig.SMTPHost; got != want { t.Errorf("expected %#v to be %#v", got, want) } } // Update value - smsConfig.TwilioAuthToken = "banana123" - if err := db.SaveSMSConfig(smsConfig); err != nil { + emailConfig.SMTPPassword = "banana123" + if err := db.SaveEmailConfig(emailConfig); err != nil { t.Fatal(err) } // Read back updated value { - got, err := realm.SMSConfig(db) + got, err := realm.EmailConfig(db) if err != nil { t.Fatal(err) } if got == nil { - t.Fatalf("expected SMSConfig, got %#v", got) + t.Fatalf("expected EmailConfig, got %#v", got) } - if got, want := got.TwilioAuthToken, "banana123"; got != want { + if got, want := got.SMTPPassword, "banana123"; got != want { t.Errorf("expected %#v to be %#v", got, want) } } } -func TestSMSProvider(t *testing.T) { +func TestEmailProvider(t *testing.T) { t.Parallel() db := NewTestDatabase(t) - realm, err := db.CreateRealm("test-sms-realm-1") + realm, err := db.CreateRealm("test-email-realm-1") if err != nil { t.Fatalf("realm create failed: %v", err) } - provider, err := realm.SMSProvider(db) + provider, err := realm.EmailProvider(db) if err != nil { t.Fatal(err) } @@ -131,18 +131,18 @@ func TestSMSProvider(t *testing.T) { t.Errorf("expected %v to be %v", provider, nil) } - smsConfig := &SMSConfig{ - RealmID: realm.ID, - ProviderType: sms.ProviderType("TWILIO"), - TwilioAccountSid: "abc123", - TwilioAuthToken: "my-secret-ref", - TwilioFromNumber: "+11234567890", + emailConfig := &EmailConfig{ + RealmID: realm.ID, + ProviderType: email.ProviderType(email.ProviderTypeSMTP), + SMTPAccount: "noreply@sendemails.meh", + SMTPPassword: "my-secret-ref", + SMTPHost: "smtp.sendemails.meh", } - if err := db.SaveSMSConfig(smsConfig); err != nil { + if err := db.SaveEmailConfig(emailConfig); err != nil { t.Fatal(err) } - provider, err = realm.SMSProvider(db) + provider, err = realm.EmailProvider(db) if err != nil { t.Fatal(err) } diff --git a/pkg/database/realm.go b/pkg/database/realm.go index a38b5fdd7..ba0eec72b 100644 --- a/pkg/database/realm.go +++ b/pkg/database/realm.go @@ -24,6 +24,7 @@ import ( "strings" "time" + "github.com/google/exposure-notifications-verification-server/pkg/email" "github.com/google/exposure-notifications-verification-server/pkg/sms" "github.com/microcosm-cc/bluemonday" @@ -140,6 +141,26 @@ type Realm struct { // configuration, making it impossible to opt out of text message sending. UseSystemSMSConfig bool `gorm:"column:use_system_sms_config; type:bool; not null; default:false;"` + // EmailInviteTemplate is the template for inviting new users. + EmailInviteTemplate string `gorm:"type:varchar(400);"` + + // EmailPasswordResetTemplate is the template for resetting password. + EmailPasswordResetTemplate string `gorm:"type:varchar(400);"` + + // EmailVerifyTemplate is the template used for email verification. + EmailVerifyTemplate string `gorm:"type:varchar(400);"` + + // CanUseSystemEmailConfig is configured by system administrators to share the + // system email config with this realm. Note that the system email config could be + // empty and a local SMS config is preferred over the system value. + CanUseSystemEmailConfig bool `gorm:"column:can_use_system_sms_config; type:bool; not null; default:false;"` + + // UseSystemEmailConfig is a realm-level configuration that lets a realm opt-out + // of sending email messages using the system-provided email configuration. + // Without this, a realm would always fallback to the system-level email + // configuration, making it impossible to opt out of text message sending. + UseSystemEmailConfig bool `gorm:"column:use_system_sms_config; type:bool; not null; default:false;"` + // MFAMode represents the mode for Multi-Factor-Authorization requirements for the realm. MFAMode AuthRequirement `gorm:"type:smallint; not null; default: 0"` @@ -459,6 +480,75 @@ func (r *Realm) SMSProvider(db *Database) (sms.Provider, error) { return provider, nil } +// EmailConfig returns the SMS configuration for this realm, if one exists. If the +// realm is configured to use the system SMS configuration, that configuration +// is preferred. +func (r *Realm) EmailConfig(db *Database) (*EmailConfig, error) { + q := db.db. + Model(&EmailConfig{}). + Order("is_system DESC"). + Where("realm_id = ?", r.ID) + + if r.UseSystemEmailConfig { + q = q.Or("is_system IS TRUE") + } + + var emailConfig EmailConfig + if err := q.First(&emailConfig).Error; err != nil { + return nil, err + } + return &emailConfig, nil +} + +// HasEmailConfig returns true if the realm has an email config, false otherwise. +// This does not perform the KMS encryption/decryption, so it's more efficient +// that loading the full email config. +func (r *Realm) HasEmailConfig(db *Database) (bool, error) { + q := db.db. + Model(&EmailConfig{}). + Select("id"). + Order("is_system DESC"). + Where("realm_id = ?", r.ID) + + if r.UseSystemEmailConfig { + q = q.Or("is_system IS TRUE") + } + + var id []uint64 + if err := q.Pluck("id", &id).Error; err != nil { + if IsNotFound(err) { + return false, nil + } + return false, err + } + return len(id) > 0, nil +} + +// EmailProvider returns the email provider for the realm. If no email configuration +// exists, it returns nil. If any errors occur creating the provider, they are +// returned. +func (r *Realm) EmailProvider(db *Database) (email.Provider, error) { + emailConfig, err := r.EmailConfig(db) + if err != nil { + if IsNotFound(err) { + return nil, nil + } + return nil, err + } + + ctx := context.Background() + provider, err := email.ProviderFor(ctx, &email.Config{ + User: emailConfig.SMTPAccount, + Password: emailConfig.SMTPPassword, + SMTPHost: emailConfig.SMTPHost, + SMTPPort: emailConfig.SMTPPort, + }) + if err != nil { + return nil, err + } + return provider, nil +} + func (r *Realm) Audits(db *Database) ([]*AuditEntry, error) { var entries []*AuditEntry if err := db.db. diff --git a/pkg/email/config.go b/pkg/email/config.go index 48f99e954..ca0f87940 100644 --- a/pkg/email/config.go +++ b/pkg/email/config.go @@ -18,9 +18,7 @@ import ( "context" "fmt" - "firebase.google.com/go/auth" "github.com/google/exposure-notifications-server/pkg/secrets" - "github.com/google/exposure-notifications-verification-server/pkg/render" ) // ProviderType represents a type of email provider. @@ -30,10 +28,6 @@ const ( // ProviderTypeNoop is a no-op provider ProviderTypeNoop ProviderType = "NOOP" - // ProviderTypeFirebase falls back to firebase's default email template. - // it uses password-reset rather than a true invitation. - ProviderTypeFirebase ProviderType = "FIREBASE" - // ProviderTypeSMTP composes emails and sends them via an external SMTP server. ProviderTypeSMTP ProviderType = "SIMPLE_SMTP" ) @@ -65,8 +59,8 @@ type Config struct { // Provider is an interface for email-sending mechanisms. type Provider interface { - // SendNewUserInvitation sends an invite to join the server. - SendNewUserInvitation(ctx context.Context, email string) error + // SendEmail sends an email with the given message. + SendEmail(ctx context.Context, toEmail string, message []byte) error } // HasSMTPCreds returns true if required fields for connecting to SMTP are set. @@ -75,14 +69,12 @@ func (c *Config) HasSMTPCreds() bool { } // ProviderFor creates an email provider given a Config. -func ProviderFor(ctx context.Context, c *Config, h *render.Renderer, auth *auth.Client) (Provider, error) { +func ProviderFor(ctx context.Context, c *Config) (Provider, error) { switch typ := c.ProviderType; typ { case ProviderTypeNoop: return NewNoop(), nil - case ProviderTypeFirebase: - return NewFirebase(ctx) case ProviderTypeSMTP: - return NewSMTP(ctx, c.User, c.Password, c.SMTPHost, c.SMTPPort, h, auth), nil + return NewSMTP(ctx, c.User, c.Password, c.SMTPHost, c.SMTPPort), nil default: return nil, fmt.Errorf("unknown email provider type: %v", typ) } diff --git a/pkg/email/firebase.go b/pkg/email/firebase.go deleted file mode 100644 index 3ca9d8a4b..000000000 --- a/pkg/email/firebase.go +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2020 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Package email is logic for sending email invitations -package email - -import ( - "context" - "fmt" - - "github.com/google/exposure-notifications-verification-server/internal/firebase" -) - -var _ Provider = (*firebase.Client)(nil) - -// NewFirebase creates a new SMTP email sender with the given auth. -func NewFirebase(ctx context.Context) (Provider, error) { - firebaseInternal, err := firebase.New(ctx) - if err != nil { - return nil, fmt.Errorf("failed to configure internal firebase client: %w", err) - } - return firebaseInternal, nil -} diff --git a/pkg/email/noop.go b/pkg/email/noop.go index d6580edb7..9f941308c 100644 --- a/pkg/email/noop.go +++ b/pkg/email/noop.go @@ -31,9 +31,9 @@ func NewNoop() Provider { return &NoopProvider{} } -// SendNewUserInvitation sends a password reset email to the user. -func (s *NoopProvider) SendNewUserInvitation(ctx context.Context, toEmail string) error { +// SendEmail sends a password reset email to the user. +func (s *NoopProvider) SendEmail(ctx context.Context, toEmail string, message []byte) error { logger := logging.FromContext(ctx) - logger.Infow("Noop send invitation", "email", toEmail) + logger.Infow("Noop send email", "email", toEmail) return nil } diff --git a/pkg/email/smtp.go b/pkg/email/smtp.go index cd48727ee..b49f3c7f2 100644 --- a/pkg/email/smtp.go +++ b/pkg/email/smtp.go @@ -19,20 +19,13 @@ import ( "context" "net/smtp" - "firebase.google.com/go/auth" "github.com/google/exposure-notifications-server/pkg/logging" - "github.com/google/exposure-notifications-verification-server/pkg/controller" - "github.com/google/exposure-notifications-verification-server/pkg/render" ) var _ Provider = (*SMTPProvider)(nil) // SMTPProvider sends messages via an external SMTP server. type SMTPProvider struct { - FirebaseAuth *auth.Client - - Renderer *render.Renderer - User string Password string SMTPHost string @@ -40,45 +33,17 @@ type SMTPProvider struct { } // NewSMTP creates a new Smtp email sender with the given auth. -func NewSMTP(ctx context.Context, user, password, host, port string, h *render.Renderer, auth *auth.Client) Provider { +func NewSMTP(ctx context.Context, user, password, host, port string) Provider { return &SMTPProvider{ - FirebaseAuth: auth, - Renderer: h, - User: user, - Password: password, - SMTPHost: host, - SMTPPort: port, + User: user, + Password: password, + SMTPHost: host, + SMTPPort: port, } } -// SendNewUserInvitation sends a password reset email to the user. -func (s *SMTPProvider) SendNewUserInvitation(ctx context.Context, toEmail string) error { - inviteLink, err := s.FirebaseAuth.PasswordResetLink(ctx, toEmail) - if err != nil { - return err - } - - realmName := "" - if realm := controller.RealmFromContext(ctx); realm != nil { - realmName = realm.Name - } - - // Compose message - message, err := s.Renderer.RenderEmail("email/invite", - struct { - ToEmail string - FromEmail string - InviteLink string - RealmName string - }{ - ToEmail: toEmail, - FromEmail: s.User, - InviteLink: inviteLink, - RealmName: realmName, - }) - if err != nil { - return err - } +// SendEmail sends an email to the user. +func (s *SMTPProvider) SendEmail(ctx context.Context, toEmail string, message []byte) error { // Authentication. auth := smtp.PlainAuth("", s.User, s.Password, s.SMTPHost) From 3a1dd1081cdc64afb199e30dc8c8dc7b1f272e60 Mon Sep 17 00:00:00 2001 From: Weston Haught Date: Thu, 15 Oct 2020 10:19:44 -0700 Subject: [PATCH 04/31] fixes form integrate --- internal/firebase/password_reset_email.go | 2 +- pkg/controller/controller.go | 28 ----------------------- pkg/database/realm.go | 5 ++-- 3 files changed, 4 insertions(+), 31 deletions(-) diff --git a/internal/firebase/password_reset_email.go b/internal/firebase/password_reset_email.go index c72996ad4..9573df81e 100644 --- a/internal/firebase/password_reset_email.go +++ b/internal/firebase/password_reset_email.go @@ -36,7 +36,7 @@ type sendPasswordResetEmailRequest struct { // face rate-limiting on the firebase side if called too quickly. // // See: https://firebase.google.com/docs/reference/rest/auth#section-send-password-reset-email -func (c *Client) SendNewUserInvitation(ctx context.Context, email, realmName string) error { +func (c *Client) SendNewUserInvitation(ctx context.Context, email string) error { r := &sendPasswordResetEmailRequest{ RequestType: "PASSWORD_RESET", Email: email, diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 2c3494bb2..fd9757c65 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -21,7 +21,6 @@ import ( "net/http" "strings" - "firebase.google.com/go/auth" "github.com/google/exposure-notifications-verification-server/pkg/api" "github.com/google/exposure-notifications-verification-server/pkg/render" @@ -141,33 +140,6 @@ func RedirectToChangePassword(w http.ResponseWriter, r *http.Request, h *render. http.Redirect(w, r, "/login/change-password", http.StatusSeeOther) } -// ComposeInviteEmail uses the renderer and auth client to generate a password reset link -// and emit an invite email -func ComposeInviteEmail(h *render.Renderer, auth *auth.Client, toEmail, fromEmail, realmName string) (string, error) { - inviteLink, err := auth.PasswordResetLink(ctx, toEmail) - if err != nil { - return err - } - - // Compose message - message, err := h.RenderEmail("email/invite", - struct { - ToEmail string - FromEmail string - InviteLink string - RealmName string - }{ - ToEmail: toEmail, - FromEmail: fromEmail, - InviteLink: inviteLink, - RealmName: realmName, - }) - if err != nil { - return nil, err - } - return message, nil -} - func prefixInList(list []string, prefix string) bool { for _, v := range list { if strings.HasPrefix(v, prefix) { diff --git a/pkg/database/realm.go b/pkg/database/realm.go index a904bf390..9c2e108be 100644 --- a/pkg/database/realm.go +++ b/pkg/database/realm.go @@ -25,6 +25,7 @@ import ( "time" "github.com/google/exposure-notifications-verification-server/pkg/digest" + "github.com/google/exposure-notifications-verification-server/pkg/email" "github.com/google/exposure-notifications-verification-server/pkg/sms" "github.com/microcosm-cc/bluemonday" @@ -154,13 +155,13 @@ type Realm struct { // CanUseSystemEmailConfig is configured by system administrators to share the // system email config with this realm. Note that the system email config could be // empty and a local SMS config is preferred over the system value. - CanUseSystemEmailConfig bool `gorm:"column:can_use_system_sms_config; type:bool; not null; default:false;"` + CanUseSystemEmailConfig bool `gorm:"column:can_use_system_email_config; type:bool; not null; default:false;"` // UseSystemEmailConfig is a realm-level configuration that lets a realm opt-out // of sending email messages using the system-provided email configuration. // Without this, a realm would always fallback to the system-level email // configuration, making it impossible to opt out of text message sending. - UseSystemEmailConfig bool `gorm:"column:use_system_sms_config; type:bool; not null; default:false;"` + UseSystemEmailConfig bool `gorm:"column:use_system_email_config; type:bool; not null; default:false;"` // MFAMode represents the mode for Multi-Factor-Authorization requirements for the realm. MFAMode AuthRequirement `gorm:"type:smallint; not null; default: 0"` From eca29c84a368268ed856825d3dbf6368a345e93d Mon Sep 17 00:00:00 2001 From: Weston Haught Date: Thu, 15 Oct 2020 11:01:57 -0700 Subject: [PATCH 05/31] update migrations --- pkg/database/email_config_test.go | 2 +- pkg/database/migrations.go | 70 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/pkg/database/email_config_test.go b/pkg/database/email_config_test.go index f0c9560b6..6887c1eac 100644 --- a/pkg/database/email_config_test.go +++ b/pkg/database/email_config_test.go @@ -46,7 +46,7 @@ func TestEmailConfig_Lifecycle(t *testing.T) { // Create email config on the realm emailConfig := &EmailConfig{ RealmID: realm.ID, - ProviderType: email.ProviderType("TWILIO"), + ProviderType: email.ProviderType(email.ProviderTypeSMTP), SMTPAccount: "noreply@sendemails.meh", SMTPPassword: "my-secret-ref", SMTPHost: "smtp.sendemails.meh", diff --git a/pkg/database/migrations.go b/pkg/database/migrations.go index 705ff5498..cd5786e31 100644 --- a/pkg/database/migrations.go +++ b/pkg/database/migrations.go @@ -1465,6 +1465,76 @@ func (db *Database) getMigrations(ctx context.Context) *gormigrate.Gormigrate { return nil }, }, + { + ID: "00060-AddEmailConfig", + Migrate: func(tx *gorm.DB) error { + logger.Debugw("adding email_configs table") + return tx.AutoMigrate(&EmailConfig{}).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.DropTable("email_configs").Error + }, + }, + { + ID: "00061-CreateSystemEmailConfig", + Migrate: func(tx *gorm.DB) error { + sqls := []string{ + // Add a new is_system boolean column and a constraint to ensure that + // only one row can have a value of true. + `CREATE UNIQUE INDEX IF NOT EXISTS uix_email_configs_is_system_true ON email_configs (is_system) WHERE (is_system IS TRUE)`, + + // Require realm_id be set on all rows except system configs, and + // ensure that realm_id is unique. + `ALTER TABLE email_configs DROP CONSTRAINT IF EXISTS nn_email_configs_realm_id`, + `DROP INDEX IF EXISTS nn_email_configs_realm_id`, + `ALTER TABLE email_configs ADD CONSTRAINT nn_email_configs_realm_id CHECK (is_system IS TRUE OR realm_id IS NOT NULL)`, + + `ALTER TABLE email_configs DROP CONSTRAINT IF EXISTS uix_email_configs_realm_id`, + `DROP INDEX IF EXISTS uix_email_configs_realm_id`, + `ALTER TABLE email_configs ADD CONSTRAINT uix_email_configs_realm_id UNIQUE (realm_id)`, + + // Realm option set by system admins to share the system Email config + // with the realm. + `ALTER TABLE realms ADD COLUMN IF NOT EXISTS can_use_system_email_config BOOL`, + `UPDATE realms SET can_use_system_email_config = FALSE WHERE can_use_system_email_config IS NULL`, + `ALTER TABLE realms ALTER COLUMN can_use_system_email_config SET DEFAULT FALSE`, + `ALTER TABLE realms ALTER COLUMN can_use_system_email_config SET NOT NULL`, + + // If true, the realm is set to use the system Email config. + `ALTER TABLE realms ADD COLUMN IF NOT EXISTS use_system_email_config BOOL`, + `UPDATE realms SET use_system_email_config = FALSE WHERE use_system_email_config IS NULL`, + `ALTER TABLE realms ALTER COLUMN use_system_email_config SET DEFAULT FALSE`, + `ALTER TABLE realms ALTER COLUMN use_system_email_config SET NOT NULL`, + } + + for _, sql := range sqls { + if err := tx.Exec(sql).Error; err != nil { + return err + } + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + sqls := []string{ + `ALTER TABLE sms_configs DROP COLUMN IF EXISTS is_system`, + `DROP INDEX IF EXISTS uix_sms_configs_is_system_true`, + `ALTER TABLE sms_configs DROP CONSTRAINT IF EXISTS nn_sms_configs_realm_id`, + `ALTER TABLE sms_configs DROP CONSTRAINT IF EXISTS uix_sms_configs_realm_id`, + + `ALTER TABLE realms DROP COLUMN IF EXISTS can_use_system_sms_config`, + `ALTER TABLE realms DROP COLUMN IF EXISTS use_system_sms_config`, + } + + for _, sql := range sqls { + if err := tx.Exec(sql).Error; err != nil { + return err + } + } + + return nil + }, + }, }) } From 1563e26dad16d1af4a00ae9d120c4e76978e950a Mon Sep 17 00:00:00 2001 From: Weston Haught Date: Thu, 15 Oct 2020 14:10:01 -0700 Subject: [PATCH 06/31] some feedback --- cmd/server/assets/realmadmin/_form_email.html | 2 +- cmd/server/assets/realmadmin/edit.html | 2 +- pkg/database/email_config.go | 2 +- pkg/database/realm.go | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/server/assets/realmadmin/_form_email.html b/cmd/server/assets/realmadmin/_form_email.html index d1b285645..70a6342f3 100644 --- a/cmd/server/assets/realmadmin/_form_email.html +++ b/cmd/server/assets/realmadmin/_form_email.html @@ -32,7 +32,7 @@
{{end}} -
+
diff --git a/cmd/server/assets/realmadmin/edit.html b/cmd/server/assets/realmadmin/edit.html index 637031b1c..4e1ad8034 100644 --- a/cmd/server/assets/realmadmin/edit.html +++ b/cmd/server/assets/realmadmin/edit.html @@ -40,7 +40,7 @@

Realm settings

SMS