From 229d87fc2d0e1d55a2eec13c2247abcdc7909e43 Mon Sep 17 00:00:00 2001 From: viovanov Date: Wed, 27 Sep 2023 10:38:29 +0300 Subject: [PATCH] support for secondary recovery emails emails --- mocks/secondary_emails_mocks.go | 43 ++++++++++++ recover/recover.go | 16 +++-- recover/recover_secondary_emails_test.go | 87 ++++++++++++++++++++++++ user.go | 13 ++++ 4 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 mocks/secondary_emails_mocks.go create mode 100644 recover/recover_secondary_emails_test.go diff --git a/mocks/secondary_emails_mocks.go b/mocks/secondary_emails_mocks.go new file mode 100644 index 0000000..904edaa --- /dev/null +++ b/mocks/secondary_emails_mocks.go @@ -0,0 +1,43 @@ +package mocks + +import ( + "context" + + "github.com/volatiletech/authboss/v3" +) + +type UserWithSecondaryEmails struct { + User + SecondaryEmails []string +} + +// GetSecondaryEmails for the user +func (u *UserWithSecondaryEmails) GetSecondaryEmails() []string { + return u.SecondaryEmails +} + +type ServerStorerWithSecondaryEmails struct { + BasicStorer *ServerStorer +} + +func (s ServerStorerWithSecondaryEmails) Load(ctx context.Context, key string) (authboss.User, error) { + user, err := s.BasicStorer.Load(ctx, key) + if err != nil { + return user, err + } + + mockedUser := user.(*User) + + return &UserWithSecondaryEmails{ + User: *mockedUser, + SecondaryEmails: []string{"personal@one.com", "personal@two.com"}, + }, nil +} + +func (s ServerStorerWithSecondaryEmails) Save(ctx context.Context, user authboss.User) error { + if u, ok := user.(*UserWithSecondaryEmails); ok { + user = &u.User + } + + return s.BasicStorer.Save(ctx, user) +} diff --git a/recover/recover.go b/recover/recover.go index 9d0a879..8ea1f17 100644 --- a/recover/recover.go +++ b/recover/recover.go @@ -118,6 +118,8 @@ func (r *Recover) StartPost(w http.ResponseWriter, req *http.Request) error { return err } + ruWithSecondaries, hasSecondaryEmails := authboss.CanBeRecoverableUserWithSecondaryEmails(user) + ru.PutRecoverSelector(selector) ru.PutRecoverVerifier(verifier) ru.PutRecoverExpiry(time.Now().UTC().Add(r.Config.Modules.RecoverTokenDuration)) @@ -126,10 +128,16 @@ func (r *Recover) StartPost(w http.ResponseWriter, req *http.Request) error { return err } + recoveryEmailRecipients := []string{ru.GetEmail()} + + if hasSecondaryEmails { + recoveryEmailRecipients = append(recoveryEmailRecipients, ruWithSecondaries.GetSecondaryEmails()...) + } + if r.Authboss.Modules.MailNoGoroutine { - r.SendRecoverEmail(req.Context(), ru.GetEmail(), token) + r.SendRecoverEmail(req.Context(), recoveryEmailRecipients, token) } else { - go r.SendRecoverEmail(req.Context(), ru.GetEmail(), token) + go r.SendRecoverEmail(req.Context(), recoveryEmailRecipients, token) } _, err = r.Authboss.Events.FireAfter(authboss.EventRecoverStart, w, req) @@ -148,13 +156,13 @@ func (r *Recover) StartPost(w http.ResponseWriter, req *http.Request) error { // SendRecoverEmail to a specific e-mail address passing along the encodedToken // in an escaped URL to the templates. -func (r *Recover) SendRecoverEmail(ctx context.Context, to, encodedToken string) { +func (r *Recover) SendRecoverEmail(ctx context.Context, to []string, encodedToken string) { logger := r.Authboss.Logger(ctx) mailURL := r.mailURL(encodedToken) email := authboss.Email{ - To: []string{to}, + To: to, From: r.Authboss.Config.Mail.From, FromName: r.Authboss.Config.Mail.FromName, Subject: r.Authboss.Config.Mail.SubjectPrefix + "Password Reset", diff --git a/recover/recover_secondary_emails_test.go b/recover/recover_secondary_emails_test.go new file mode 100644 index 0000000..6e18b71 --- /dev/null +++ b/recover/recover_secondary_emails_test.go @@ -0,0 +1,87 @@ +package recover + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/volatiletech/authboss/v3" + "github.com/volatiletech/authboss/v3/mocks" +) + +func testSetupWithSecondaryEmails() *testHarness { + harness := &testHarness{} + + harness.ab = authboss.New() + harness.bodyReader = &mocks.BodyReader{} + harness.mailer = &mocks.Emailer{} + harness.redirector = &mocks.Redirector{} + harness.renderer = &mocks.Renderer{} + harness.responder = &mocks.Responder{} + harness.session = mocks.NewClientRW() + harness.storer = mocks.NewServerStorer() + + harness.ab.Paths.RecoverOK = "/recover/ok" + harness.ab.Modules.MailNoGoroutine = true + + harness.ab.Config.Core.BodyReader = harness.bodyReader + harness.ab.Config.Core.Logger = mocks.Logger{} + harness.ab.Config.Core.Mailer = harness.mailer + harness.ab.Config.Core.Redirector = harness.redirector + harness.ab.Config.Core.MailRenderer = harness.renderer + harness.ab.Config.Core.Responder = harness.responder + harness.ab.Config.Storage.SessionState = harness.session + harness.ab.Config.Storage.Server = mocks.ServerStorerWithSecondaryEmails{ + BasicStorer: harness.storer, + } + + harness.recover = &Recover{harness.ab} + + return harness +} + +func TestSecondaryEmails(t *testing.T) { + t.Parallel() + + h := testSetupWithSecondaryEmails() + + h.bodyReader.Return = &mocks.Values{ + PID: "test@test.com", + } + h.storer.Users["test@test.com"] = &mocks.User{ + Email: "test@test.com", + Password: "i can't recall, doesn't seem like something bcrypted though", + } + + r := mocks.Request("GET") + w := httptest.NewRecorder() + + if err := h.recover.StartPost(w, r); err != nil { + t.Error(err) + } + + if w.Code != http.StatusTemporaryRedirect { + t.Error("code was wrong:", w.Code) + } + if h.redirector.Options.RedirectPath != h.ab.Config.Paths.RecoverOK { + t.Error("page was wrong:", h.responder.Page) + } + if len(h.redirector.Options.Success) == 0 { + t.Error("expected a nice success message") + } + + if h.mailer.Email.To[0] != "test@test.com" { + t.Error("e-mail to address is wrong:", h.mailer.Email.To) + } + if !strings.HasSuffix(h.mailer.Email.Subject, "Password Reset") { + t.Error("e-mail subject line is wrong:", h.mailer.Email.Subject) + } + if len(h.renderer.Data[DataRecoverURL].(string)) == 0 { + t.Errorf("the renderer's url in data was missing: %#v", h.renderer.Data) + } + + if len(h.mailer.Email.To) != 3 { + t.Errorf("should have sent 3 e-mails out, but sent %d", len(h.mailer.Email.To)) + } +} diff --git a/user.go b/user.go index b97ba84..8bb443d 100644 --- a/user.go +++ b/user.go @@ -73,6 +73,12 @@ type RecoverableUser interface { PutRecoverExpiry(expiry time.Time) } +type RecoverableUserWithSecondaryEmails interface { + RecoverableUser + + GetSecondaryEmails() (secondaryEmails []string) +} + // ArbitraryUser allows arbitrary data from the web form through. You should // definitely only pull the keys you want from the map, since this is unfiltered // input from a web request and is an attack vector. @@ -142,6 +148,13 @@ func MustBeRecoverable(u User) RecoverableUser { panic(fmt.Sprintf("could not upgrade user to a recoverable user, given type: %T", u)) } +func CanBeRecoverableUserWithSecondaryEmails(u User) (RecoverableUserWithSecondaryEmails, bool) { + if lu, ok := u.(RecoverableUserWithSecondaryEmails); ok { + return lu, true + } + return nil, false +} + // MustBeOAuthable forces an upgrade to an OAuth2User or panic. func MustBeOAuthable(u User) OAuth2User { if ou, ok := u.(OAuth2User); ok {