diff --git a/driver/config/config.go b/driver/config/config.go index 2fd2cf4ef92f..5fdcb5703830 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -150,6 +150,7 @@ const ( ViperKeySelfServiceRecoveryUI = "selfservice.flows.recovery.ui_url" ViperKeySelfServiceRecoveryRequestLifespan = "selfservice.flows.recovery.lifespan" ViperKeySelfServiceRecoveryBrowserDefaultReturnTo = "selfservice.flows.recovery.after." + DefaultBrowserReturnURL + ViperKeySelfServiceRecoveryNotifyUnknownRecipients = "selfservice.flows.recovery.notify_unknown_recipients" ViperKeySelfServiceVerificationEnabled = "selfservice.flows.verification.enabled" ViperKeySelfServiceVerificationUI = "selfservice.flows.verification.ui_url" ViperKeySelfServiceVerificationRequestLifespan = "selfservice.flows.verification.lifespan" @@ -157,6 +158,7 @@ const ( ViperKeySelfServiceVerificationAfter = "selfservice.flows.verification.after" ViperKeySelfServiceVerificationBeforeHooks = "selfservice.flows.verification.before.hooks" ViperKeySelfServiceVerificationUse = "selfservice.flows.verification.use" + ViperKeySelfServiceVerificationNotifyUnknownRecipients = "selfservice.flows.verification.notify_unknown_recipients" ViperKeyDefaultIdentitySchemaID = "identity.default_schema_id" ViperKeyIdentitySchemas = "identity.schemas" ViperKeyHasherAlgorithm = "hashers.algorithm" @@ -656,10 +658,15 @@ func (p *Config) SelfServiceFlowRecoveryBeforeHooks(ctx context.Context) []SelfS func (p *Config) SelfServiceFlowVerificationBeforeHooks(ctx context.Context) []SelfServiceHook { return p.selfServiceHooks(ctx, ViperKeySelfServiceVerificationBeforeHooks) } + func (p *Config) SelfServiceFlowVerificationUse(ctx context.Context) string { return p.GetProvider(ctx).String(ViperKeySelfServiceVerificationUse) } +func (p *Config) SelfServiceFlowVerificationNotifyUnknownRecipients(ctx context.Context) bool { + return p.GetProvider(ctx).BoolF(ViperKeySelfServiceVerificationNotifyUnknownRecipients, false) +} + func (p *Config) SelfServiceFlowSettingsBeforeHooks(ctx context.Context) []SelfServiceHook { return p.selfServiceHooks(ctx, ViperKeySelfServiceSettingsBeforeHooks) } @@ -1193,6 +1200,10 @@ func (p *Config) SelfServiceFlowRecoveryRequestLifespan(ctx context.Context) tim return p.GetProvider(ctx).DurationF(ViperKeySelfServiceRecoveryRequestLifespan, time.Hour) } +func (p *Config) SelfServiceFlowRecoveryNotifyUnknownRecipients(ctx context.Context) bool { + return p.GetProvider(ctx).BoolF(ViperKeySelfServiceRecoveryNotifyUnknownRecipients, false) +} + func (p *Config) SelfServiceLinkMethodLifespan(ctx context.Context) time.Duration { return p.GetProvider(ctx).DurationF(ViperKeyLinkLifespan, time.Hour) } diff --git a/driver/config/config_test.go b/driver/config/config_test.go index 9832dc955b19..957779bbed33 100644 --- a/driver/config/config_test.go +++ b/driver/config/config_test.go @@ -514,6 +514,7 @@ func TestViperProvider_Defaults(t *testing.T) { assert.True(t, p.SelfServiceStrategy(ctx, "password").Enabled) assert.True(t, p.SelfServiceStrategy(ctx, "profile").Enabled) assert.True(t, p.SelfServiceStrategy(ctx, "link").Enabled) + assert.True(t, p.SelfServiceStrategy(ctx, "code").Enabled) assert.False(t, p.SelfServiceStrategy(ctx, "oidc").Enabled) }, }, @@ -531,6 +532,15 @@ func TestViperProvider_Defaults(t *testing.T) { assert.True(t, p.SelfServiceStrategy(ctx, "oidc").Enabled) }, }, + { + init: func() *config.Config { + return config.MustNew(t, l, os.Stderr, configx.WithConfigFiles("stub/.kratos.notify-unknown-recipients.yml"), configx.SkipValidation()) + }, + expect: func(t *testing.T, p *config.Config) { + assert.True(t, p.SelfServiceFlowRecoveryNotifyUnknownRecipients(ctx)) + assert.True(t, p.SelfServiceFlowVerificationNotifyUnknownRecipients(ctx)) + }, + }, } { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { p := tc.init() @@ -546,6 +556,9 @@ func TestViperProvider_Defaults(t *testing.T) { assert.False(t, p.SelfServiceStrategy(ctx, "link").Enabled) assert.True(t, p.SelfServiceStrategy(ctx, "code").Enabled) assert.False(t, p.SelfServiceStrategy(ctx, "oidc").Enabled) + + assert.False(t, p.SelfServiceFlowRecoveryNotifyUnknownRecipients(ctx)) + assert.False(t, p.SelfServiceFlowVerificationNotifyUnknownRecipients(ctx)) }) } diff --git a/driver/config/stub/.kratos.notify-unknown-recipients.yml b/driver/config/stub/.kratos.notify-unknown-recipients.yml new file mode 100644 index 000000000000..b0f84f480ed3 --- /dev/null +++ b/driver/config/stub/.kratos.notify-unknown-recipients.yml @@ -0,0 +1,6 @@ +selfservice: + flows: + recovery: + notify_unknown_recipients: true + verification: + notify_unknown_recipients: true diff --git a/embedx/config.schema.json b/embedx/config.schema.json index bd07d2e43643..330c87642be3 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -1230,6 +1230,12 @@ "code" ], "default": "code" + }, + "notify_unknown_recipients": { + "title": "Notify unknown recipients", + "description": "Whether to notify recipients, if verification was requested for their address.", + "type": "boolean", + "default": false } } }, @@ -1281,6 +1287,12 @@ "code" ], "default": "code" + }, + "notify_unknown_recipients": { + "title": "Notify unknown recipients", + "description": "Whether to notify recipients, if recovery was requested for their account.", + "type": "boolean", + "default": false } } }, diff --git a/selfservice/strategy/code/code_sender.go b/selfservice/strategy/code/code_sender.go index ea265b4c2b2d..5f48437131b5 100644 --- a/selfservice/strategy/code/code_sender.go +++ b/selfservice/strategy/code/code_sender.go @@ -5,7 +5,6 @@ package code import ( "context" - "net/http" "net/url" "github.com/gofrs/uuid" @@ -59,21 +58,35 @@ func NewSender(deps senderDependencies) *Sender { return &Sender{deps: deps} } -// SendRecoveryCode sends a recovery code to the specified address. -// If the address does not exist in the store, an email is still being sent to prevent account -// enumeration attacks. In that case, this function returns the ErrUnknownAddress error. -func (s *Sender) SendRecoveryCode(ctx context.Context, r *http.Request, f *recovery.Flow, via identity.VerifiableAddressType, to string) error { +// SendRecoveryCode sends a recovery code to the specified address +// +// If the address does not exist in the store and dispatching invalid emails is enabled (CourierEnableInvalidDispatch is +// true), an email is still being sent to prevent account enumeration attacks. In that case, this function returns the +// ErrUnknownAddress error. +func (s *Sender) SendRecoveryCode(ctx context.Context, f *recovery.Flow, via identity.VerifiableAddressType, to string) error { s.deps.Logger(). WithField("via", via). WithSensitiveField("address", to). Debug("Preparing recovery code.") address, err := s.deps.IdentityPool().FindRecoveryAddressByValue(ctx, identity.RecoveryAddressTypeEmail, to) - if err != nil { - if err := s.send(ctx, string(via), email.NewRecoveryCodeInvalid(s.deps, &email.RecoveryCodeInvalidModel{To: to})); err != nil { + if errors.Is(err, sqlcon.ErrNoRows) { + notifyUnknownRecipients := s.deps.Config().SelfServiceFlowRecoveryNotifyUnknownRecipients(ctx) + s.deps.Audit(). + WithField("via", via). + WithSensitiveField("email_address", address). + WithField("strategy", "code"). + WithField("was_notified", notifyUnknownRecipients). + Info("Account recovery was requested for an unknown address.") + if !notifyUnknownRecipients { + // do nothing + } else if err := s.send(ctx, string(via), email.NewRecoveryCodeInvalid(s.deps, &email.RecoveryCodeInvalidModel{To: to})); err != nil { return err } - return ErrUnknownAddress + return errors.WithStack(ErrUnknownAddress) + } else if err != nil { + // DB error + return err } // Get the identity associated with the recovery address @@ -90,7 +103,7 @@ func (s *Sender) SendRecoveryCode(ctx context.Context, r *http.Request, f *recov CreateRecoveryCode(ctx, &CreateRecoveryCodeParams{ RawCode: rawCode, CodeType: RecoveryCodeTypeSelfService, - ExpiresIn: s.deps.Config().SelfServiceCodeMethodLifespan(r.Context()), + ExpiresIn: s.deps.Config().SelfServiceCodeMethodLifespan(ctx), RecoveryAddress: address, FlowID: f.ID, IdentityID: i.ID, @@ -124,9 +137,11 @@ func (s *Sender) SendRecoveryCodeTo(ctx context.Context, i *identity.Identity, c return s.send(ctx, string(code.RecoveryAddress.Via), email.NewRecoveryCodeValid(s.deps, &emailModel)) } -// SendVerificationCode sends a verification link to the specified address. If the address does not exist in the store, an email is -// still being sent to prevent account enumeration attacks. In that case, this function returns the ErrUnknownAddress -// error. +// SendVerificationCode sends a verification code & link to the specified address +// +// If the address does not exist in the store and dispatching invalid emails is enabled (CourierEnableInvalidDispatch is +// true), an email is still being sent to prevent account enumeration attacks. In that case, this function returns the +// ErrUnknownAddress error. func (s *Sender) SendVerificationCode(ctx context.Context, f *verification.Flow, via identity.VerifiableAddressType, to string) error { s.deps.Logger(). WithField("via", via). @@ -134,17 +149,22 @@ func (s *Sender) SendVerificationCode(ctx context.Context, f *verification.Flow, Debug("Preparing verification code.") address, err := s.deps.IdentityPool().FindVerifiableAddressByValue(ctx, via, to) - if err != nil { - if errors.Is(err, sqlcon.ErrNoRows) { - s.deps.Audit(). - WithField("via", via). - WithSensitiveField("email_address", address). - Info("Sending out invalid verification via code email because address is unknown.") - if err := s.send(ctx, string(via), email.NewVerificationCodeInvalid(s.deps, &email.VerificationCodeInvalidModel{To: to})); err != nil { - return err - } - return errors.Cause(ErrUnknownAddress) + if errors.Is(err, sqlcon.ErrNoRows) { + notifyUnknownRecipients := s.deps.Config().SelfServiceFlowVerificationNotifyUnknownRecipients(ctx) + s.deps.Audit(). + WithField("via", via). + WithField("strategy", "code"). + WithSensitiveField("email_address", address). + WithField("was_notified", notifyUnknownRecipients). + Info("Address verification was requested for an unknown address.") + if !notifyUnknownRecipients { + // do nothing + } else if err := s.send(ctx, string(via), email.NewVerificationCodeInvalid(s.deps, &email.VerificationCodeInvalidModel{To: to})); err != nil { + return err } + return errors.WithStack(ErrUnknownAddress) + + } else if err != nil { return err } diff --git a/selfservice/strategy/code/code_sender_test.go b/selfservice/strategy/code/code_sender_test.go index 5e6a93d1c8aa..6b74bf4f1c53 100644 --- a/selfservice/strategy/code/code_sender_test.go +++ b/selfservice/strategy/code/code_sender_test.go @@ -8,10 +8,10 @@ import ( "encoding/base64" "fmt" "net/http" - "net/http/httptest" "testing" "time" + "github.com/ory/kratos/courier" "github.com/ory/kratos/internal/testhelpers" "github.com/stretchr/testify/assert" @@ -38,14 +38,14 @@ func TestSender(t *testing.T) { conf.MustSet(ctx, config.ViperKeyPublicBaseURL, "https://www.ory.sh/") conf.MustSet(ctx, config.ViperKeyCourierSMTPURL, "smtp://foo@bar@dev.null/") conf.MustSet(ctx, config.ViperKeyLinkBaseURL, "https://link-url/") + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, true) + conf.MustSet(ctx, config.ViperKeySelfServiceVerificationNotifyUnknownRecipients, true) u := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")} i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) i.Traits = identity.Traits(`{"email": "tracked@ory.sh"}`) - require.NoError(t, reg.IdentityManager().Create(context.Background(), i)) - - hr := httptest.NewRequest("GET", "https://www.ory.sh", nil) + require.NoError(t, reg.IdentityManager().Create(ctx, i)) t.Run("method=SendRecoveryCode", func(t *testing.T) { @@ -54,15 +54,15 @@ func TestSender(t *testing.T) { f, err := recovery.NewFlow(conf, time.Hour, "", u, code.NewStrategy(reg), flow.TypeBrowser) require.NoError(t, err) - require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(context.Background(), f)) + require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(ctx, f)) - require.NoError(t, reg.CodeSender().SendRecoveryCode(context.Background(), hr, f, "email", "tracked@ory.sh")) - require.ErrorIs(t, reg.CodeSender().SendRecoveryCode(context.Background(), hr, f, "email", "not-tracked@ory.sh"), code.ErrUnknownAddress) + require.NoError(t, reg.CodeSender().SendRecoveryCode(ctx, f, "email", "tracked@ory.sh")) + require.ErrorIs(t, reg.CodeSender().SendRecoveryCode(ctx, f, "email", "not-tracked@ory.sh"), code.ErrUnknownAddress) } t.Run("case=with default templates", func(t *testing.T) { recoveryCode(t) - messages, err := reg.CourierPersister().NextMessages(context.Background(), 12) + messages, err := reg.CourierPersister().NextMessages(ctx, 12) require.NoError(t, err) require.Len(t, messages, 2) @@ -87,7 +87,7 @@ func TestSender(t *testing.T) { conf.MustSet(ctx, config.ViperKeyCourierTemplatesRecoveryCodeInvalidEmail, fmt.Sprintf(`{ "subject": "base64://%s", "body": { "plaintext": "base64://%s", "html": "base64://%s" }}`, b64(subject+" invalid"), b64(body), b64(body))) conf.MustSet(ctx, config.ViperKeyCourierTemplatesRecoveryCodeValidEmail, fmt.Sprintf(`{ "subject": "base64://%s", "body": { "plaintext": "base64://%s", "html": "base64://%s" }}`, b64(subject+" valid"), b64(body+" {{ .RecoveryCode }}"), b64(body+" {{ .RecoveryCode }}"))) recoveryCode(t) - messages, err := reg.CourierPersister().NextMessages(context.Background(), 12) + messages, err := reg.CourierPersister().NextMessages(ctx, 12) require.NoError(t, err) require.Len(t, messages, 2) @@ -111,15 +111,15 @@ func TestSender(t *testing.T) { f, err := verification.NewFlow(conf, time.Hour, "", u, code.NewStrategy(reg), flow.TypeBrowser) require.NoError(t, err) - require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(context.Background(), f)) + require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f)) - require.NoError(t, reg.CodeSender().SendVerificationCode(context.Background(), f, "email", "tracked@ory.sh")) - require.ErrorIs(t, reg.CodeSender().SendVerificationCode(context.Background(), f, "email", "not-tracked@ory.sh"), code.ErrUnknownAddress) + require.NoError(t, reg.CodeSender().SendVerificationCode(ctx, f, "email", "tracked@ory.sh")) + require.ErrorIs(t, reg.CodeSender().SendVerificationCode(ctx, f, "email", "not-tracked@ory.sh"), code.ErrUnknownAddress) } t.Run("case=with default templates", func(t *testing.T) { verificationFlow(t) - messages, err := reg.CourierPersister().NextMessages(context.Background(), 12) + messages, err := reg.CourierPersister().NextMessages(ctx, 12) require.NoError(t, err) require.Len(t, messages, 2) @@ -144,7 +144,7 @@ func TestSender(t *testing.T) { conf.MustSet(ctx, config.ViperKeyCourierTemplatesVerificationCodeInvalidEmail, fmt.Sprintf(`{ "subject": "base64://%s", "body": { "plaintext": "base64://%s", "html": "base64://%s" }}`, b64(subject+" invalid"), b64(body), b64(body))) conf.MustSet(ctx, config.ViperKeyCourierTemplatesVerificationCodeValidEmail, fmt.Sprintf(`{ "subject": "base64://%s", "body": { "plaintext": "base64://%s", "html": "base64://%s" }}`, b64(subject+" valid"), b64(body+" {{ .VerificationCode }}"), b64(body+" {{ .VerificationCode }}"))) verificationFlow(t) - messages, err := reg.CourierPersister().NextMessages(context.Background(), 12) + messages, err := reg.CourierPersister().NextMessages(ctx, 12) require.NoError(t, err) require.Len(t, messages, 2) @@ -160,4 +160,59 @@ func TestSender(t *testing.T) { }) }) + t.Run("case=should be able to disable invalid email dispatch", func(t *testing.T) { + for _, tc := range []struct { + flow string + send func(t *testing.T) + configKey string + }{ + { + flow: "recovery", + configKey: config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, + send: func(t *testing.T) { + s, err := reg.RecoveryStrategies(ctx).Strategy("code") + require.NoError(t, err) + f, err := recovery.NewFlow(conf, time.Hour, "", u, s, flow.TypeBrowser) + require.NoError(t, err) + + require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(ctx, f)) + + err = reg.CodeSender().SendRecoveryCode(ctx, f, "email", "not-tracked@ory.sh") + require.ErrorIs(t, err, code.ErrUnknownAddress) + }, + }, + { + flow: "verification", + configKey: config.ViperKeySelfServiceVerificationNotifyUnknownRecipients, + send: func(t *testing.T) { + s, err := reg.VerificationStrategies(ctx).Strategy("code") + require.NoError(t, err) + f, err := verification.NewFlow(conf, time.Hour, "", u, s, flow.TypeBrowser) + require.NoError(t, err) + + require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f)) + + err = reg.CodeSender().SendVerificationCode(ctx, f, "email", "not-tracked@ory.sh") + require.ErrorIs(t, err, code.ErrUnknownAddress) + }, + }, + } { + t.Run("strategy="+tc.flow, func(t *testing.T) { + + conf.Set(ctx, tc.configKey, false) + + t.Cleanup(func() { + conf.Set(ctx, tc.configKey, true) + }) + + tc.send(t) + + messages, err := reg.CourierPersister().NextMessages(ctx, 0) + + require.ErrorIs(t, err, courier.ErrQueueEmpty) + require.Len(t, messages, 0) + }) + } + }) + } diff --git a/selfservice/strategy/code/strategy_recovery.go b/selfservice/strategy/code/strategy_recovery.go index b104c7df12fc..b793a51fbfdd 100644 --- a/selfservice/strategy/code/strategy_recovery.go +++ b/selfservice/strategy/code/strategy_recovery.go @@ -527,7 +527,7 @@ func (s *Strategy) recoveryHandleFormSubmission(w http.ResponseWriter, r *http.R return s.HandleRecoveryError(w, r, f, body, err) } - if err := s.deps.CodeSender().SendRecoveryCode(ctx, r, f, identity.VerifiableAddressTypeEmail, body.Email); err != nil { + if err := s.deps.CodeSender().SendRecoveryCode(ctx, f, identity.VerifiableAddressTypeEmail, body.Email); err != nil { if !errors.Is(err, ErrUnknownAddress) { return s.HandleRecoveryError(w, r, f, body, err) } diff --git a/selfservice/strategy/code/strategy_recovery_test.go b/selfservice/strategy/code/strategy_recovery_test.go index 1cc37d8df4bf..276e544a5de2 100644 --- a/selfservice/strategy/code/strategy_recovery_test.go +++ b/selfservice/strategy/code/strategy_recovery_test.go @@ -596,6 +596,12 @@ func TestRecovery(t *testing.T) { }) t.Run("description=should not be able to recover account that does not exist", func(t *testing.T) { + conf.Set(ctx, config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, true) + + t.Cleanup(func() { + conf.Set(ctx, config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, false) + }) + var check = func(t *testing.T, c *http.Client, flowType, email string) { withValues := func(v url.Values) { v.Set("email", email) diff --git a/selfservice/strategy/code/strategy_verification_test.go b/selfservice/strategy/code/strategy_verification_test.go index ce59c7593598..21ce0ee981a8 100644 --- a/selfservice/strategy/code/strategy_verification_test.go +++ b/selfservice/strategy/code/strategy_verification_test.go @@ -187,6 +187,12 @@ func TestVerification(t *testing.T) { }) t.Run("description=should try to verify an email that does not exist", func(t *testing.T) { + conf.Set(ctx, config.ViperKeySelfServiceVerificationNotifyUnknownRecipients, true) + + t.Cleanup(func() { + conf.Set(ctx, config.ViperKeySelfServiceVerificationNotifyUnknownRecipients, false) + }) + var email string var check = func(t *testing.T, actual string) { assert.EqualValues(t, string(node.CodeGroup), gjson.Get(actual, "active").String(), "%s", actual) diff --git a/selfservice/strategy/link/sender.go b/selfservice/strategy/link/sender.go index c50a5ff937e9..99b6f22732fc 100644 --- a/selfservice/strategy/link/sender.go +++ b/selfservice/strategy/link/sender.go @@ -5,7 +5,6 @@ package link import ( "context" - "net/http" "net/url" "github.com/hashicorp/go-retryablehttp" @@ -16,7 +15,6 @@ import ( "github.com/pkg/errors" - "github.com/ory/x/errorsx" "github.com/ory/x/sqlcon" "github.com/ory/x/urlx" @@ -59,21 +57,35 @@ func NewSender(r senderDependencies) *Sender { return &Sender{r: r} } -// SendRecoveryLink sends a recovery link to the specified address. If the address does not exist in the store, an email is -// still being sent to prevent account enumeration attacks. In that case, this function returns the ErrUnknownAddress -// error. -func (s *Sender) SendRecoveryLink(ctx context.Context, r *http.Request, f *recovery.Flow, via identity.VerifiableAddressType, to string) error { +// SendRecoveryLink sends a recovery link to the specified address +// +// If the address does not exist in the store and dispatching invalid emails is enabled (CourierEnableInvalidDispatch is +// true), an email is still being sent to prevent account enumeration attacks. In that case, this function returns the +// ErrUnknownAddress error. +func (s *Sender) SendRecoveryLink(ctx context.Context, f *recovery.Flow, via identity.VerifiableAddressType, to string) error { s.r.Logger(). WithField("via", via). WithSensitiveField("address", to). - Debug("Preparing verification code.") + Debug("Preparing recovery link.") address, err := s.r.IdentityPool().FindRecoveryAddressByValue(ctx, identity.RecoveryAddressTypeEmail, to) - if err != nil { - if err := s.send(ctx, string(via), email.NewRecoveryInvalid(s.r, &email.RecoveryInvalidModel{To: to})); err != nil { + if errors.Is(err, sqlcon.ErrNoRows) { + notifyUnknownRecipients := s.r.Config().SelfServiceFlowRecoveryNotifyUnknownRecipients(ctx) + s.r.Audit(). + WithField("via", via). + WithField("strategy", "link"). + WithSensitiveField("email_address", address). + WithField("was_notified", notifyUnknownRecipients). + Info("Account recovery was requested for an unknown address.") + if !notifyUnknownRecipients { + // do nothing + } else if err := s.send(ctx, string(via), email.NewRecoveryInvalid(s.r, &email.RecoveryInvalidModel{To: to})); err != nil { return err } - return errors.Cause(ErrUnknownAddress) + return errors.WithStack(ErrUnknownAddress) + } else if err != nil { + // DB error + return err } // Get the identity associated with the recovery address @@ -82,7 +94,7 @@ func (s *Sender) SendRecoveryLink(ctx context.Context, r *http.Request, f *recov return err } - token := NewSelfServiceRecoveryToken(address, f, s.r.Config().SelfServiceLinkMethodLifespan(r.Context())) + token := NewSelfServiceRecoveryToken(address, f, s.r.Config().SelfServiceLinkMethodLifespan(ctx)) if err := s.r.RecoveryTokenPersister().CreateRecoveryToken(ctx, token); err != nil { return err } @@ -94,31 +106,38 @@ func (s *Sender) SendRecoveryLink(ctx context.Context, r *http.Request, f *recov return nil } -// SendVerificationLink sends a verification link to the specified address. If the address does not exist in the store, an email is -// still being sent to prevent account enumeration attacks. In that case, this function returns the ErrUnknownAddress -// error. +// SendVerificationLink sends a verification link to the specified address +// +// If the address does not exist in the store and dispatching invalid emails is enabled (CourierEnableInvalidDispatch is +// true), an email is still being sent to prevent account enumeration attacks. In that case, this function returns the +// ErrUnknownAddress error. func (s *Sender) SendVerificationLink(ctx context.Context, f *verification.Flow, via identity.VerifiableAddressType, to string) error { s.r.Logger(). WithField("via", via). WithSensitiveField("address", to). - Debug("Preparing verification code.") + Debug("Preparing verification link.") address, err := s.r.IdentityPool().FindVerifiableAddressByValue(ctx, via, to) - if err != nil { - if errorsx.Cause(err) == sqlcon.ErrNoRows { - s.r.Audit(). - WithField("via", via). - WithSensitiveField("email_address", address). - Info("Sending out invalid verification email because address is unknown.") - if err := s.send(ctx, string(via), email.NewVerificationInvalid(s.r, &email.VerificationInvalidModel{To: to})); err != nil { - return err - } - return errors.Cause(ErrUnknownAddress) + if errors.Is(err, sqlcon.ErrNoRows) { + notifyUnknownRecipients := s.r.Config().SelfServiceFlowVerificationNotifyUnknownRecipients(ctx) + s.r.Audit(). + WithField("via", via). + WithField("strategy", "link"). + WithSensitiveField("email_address", address). + WithField("was_notified", notifyUnknownRecipients). + Info("Address verification was requested for an unknown address.") + if !notifyUnknownRecipients { + // do nothing + } else if err := s.send(ctx, string(via), email.NewVerificationInvalid(s.r, &email.VerificationInvalidModel{To: to})); err != nil { + return err } + return errors.WithStack(ErrUnknownAddress) + } else if err != nil { + // DB error return err } - // Get the identity associated with the recovery address + // Get the identity associated with the verification address i, err := s.r.IdentityPool().GetIdentity(ctx, address.IdentityID, identity.ExpandDefault) if err != nil { return err diff --git a/selfservice/strategy/link/sender_test.go b/selfservice/strategy/link/sender_test.go index a04eb24fb2bc..55dc91a9b48a 100644 --- a/selfservice/strategy/link/sender_test.go +++ b/selfservice/strategy/link/sender_test.go @@ -6,7 +6,6 @@ package link_test import ( "context" "net/http" - "net/http/httptest" "testing" "time" @@ -15,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/ory/kratos/courier" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" "github.com/ory/kratos/internal" @@ -33,6 +33,8 @@ func TestManager(t *testing.T) { conf.MustSet(ctx, config.ViperKeyPublicBaseURL, "https://www.ory.sh/") conf.MustSet(ctx, config.ViperKeyCourierSMTPURL, "smtp://foo@bar@dev.null/") conf.MustSet(ctx, config.ViperKeyLinkBaseURL, "https://link-url/") + conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, true) + conf.MustSet(ctx, config.ViperKeySelfServiceVerificationNotifyUnknownRecipients, true) u := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")} @@ -40,8 +42,6 @@ func TestManager(t *testing.T) { i.Traits = identity.Traits(`{"email": "tracked@ory.sh"}`) require.NoError(t, reg.IdentityManager().Create(context.Background(), i)) - hr := httptest.NewRequest("GET", "https://www.ory.sh", nil) - t.Run("method=SendRecoveryLink", func(t *testing.T) { s, err := reg.RecoveryStrategies(ctx).Strategy("link") require.NoError(t, err) @@ -50,8 +50,8 @@ func TestManager(t *testing.T) { require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(context.Background(), f)) - require.NoError(t, reg.LinkSender().SendRecoveryLink(context.Background(), hr, f, "email", "tracked@ory.sh")) - require.EqualError(t, reg.LinkSender().SendRecoveryLink(context.Background(), hr, f, "email", "not-tracked@ory.sh"), link.ErrUnknownAddress.Error()) + require.NoError(t, reg.LinkSender().SendRecoveryLink(context.Background(), f, "email", "tracked@ory.sh")) + require.EqualError(t, reg.LinkSender().SendRecoveryLink(context.Background(), f, "email", "not-tracked@ory.sh"), link.ErrUnknownAddress.Error()) messages, err := reg.CourierPersister().NextMessages(context.Background(), 12) require.NoError(t, err) @@ -98,4 +98,59 @@ func TestManager(t *testing.T) { require.NoError(t, err) assert.EqualValues(t, identity.VerifiableAddressStatusSent, address.Status) }) + + t.Run("case=should be able to disable invalid email dispatch", func(t *testing.T) { + for _, tc := range []struct { + flow string + send func(t *testing.T) + configKey string + }{ + { + flow: "recovery", + configKey: config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, + send: func(t *testing.T) { + s, err := reg.RecoveryStrategies(ctx).Strategy("link") + require.NoError(t, err) + f, err := recovery.NewFlow(conf, time.Hour, "", u, s, flow.TypeBrowser) + require.NoError(t, err) + + require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(context.Background(), f)) + + err = reg.LinkSender().SendRecoveryLink(context.Background(), f, "email", "not-tracked@ory.sh") + require.ErrorIs(t, err, link.ErrUnknownAddress) + }, + }, + { + flow: "verification", + configKey: config.ViperKeySelfServiceVerificationNotifyUnknownRecipients, + send: func(t *testing.T) { + s, err := reg.VerificationStrategies(ctx).Strategy("link") + require.NoError(t, err) + f, err := verification.NewFlow(conf, time.Hour, "", u, s, flow.TypeBrowser) + require.NoError(t, err) + + require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(context.Background(), f)) + + err = reg.LinkSender().SendVerificationLink(context.Background(), f, "email", "not-tracked@ory.sh") + require.ErrorIs(t, err, link.ErrUnknownAddress) + }, + }, + } { + t.Run("strategy="+tc.flow, func(t *testing.T) { + + conf.Set(ctx, tc.configKey, false) + + t.Cleanup(func() { + conf.Set(ctx, tc.configKey, true) + }) + + tc.send(t) + + messages, err := reg.CourierPersister().NextMessages(context.Background(), 0) + + require.ErrorIs(t, err, courier.ErrQueueEmpty) + require.Len(t, messages, 0) + }) + } + }) } diff --git a/selfservice/strategy/link/strategy_recovery.go b/selfservice/strategy/link/strategy_recovery.go index cefd870d08ec..24f66e15c6db 100644 --- a/selfservice/strategy/link/strategy_recovery.go +++ b/selfservice/strategy/link/strategy_recovery.go @@ -434,7 +434,7 @@ func (s *Strategy) recoveryHandleFormSubmission(w http.ResponseWriter, r *http.R return s.HandleRecoveryError(w, r, f, body, err) } - if err := s.d.LinkSender().SendRecoveryLink(r.Context(), r, f, identity.VerifiableAddressTypeEmail, body.Email); err != nil { + if err := s.d.LinkSender().SendRecoveryLink(r.Context(), f, identity.VerifiableAddressTypeEmail, body.Email); err != nil { if !errors.Is(err, ErrUnknownAddress) { return s.HandleRecoveryError(w, r, f, body, err) } diff --git a/selfservice/strategy/link/strategy_recovery_test.go b/selfservice/strategy/link/strategy_recovery_test.go index f9881bf84080..b9d9412d508f 100644 --- a/selfservice/strategy/link/strategy_recovery_test.go +++ b/selfservice/strategy/link/strategy_recovery_test.go @@ -416,6 +416,11 @@ func TestRecovery(t *testing.T) { }) t.Run("description=should try to recover an email that does not exist", func(t *testing.T) { + conf.Set(ctx, config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, true) + + t.Cleanup(func() { + conf.Set(ctx, config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, false) + }) var email string var check = func(t *testing.T, actual string) { assert.EqualValues(t, node.LinkGroup, gjson.Get(actual, "active").String(), "%s", actual) diff --git a/selfservice/strategy/link/strategy_verification_test.go b/selfservice/strategy/link/strategy_verification_test.go index 591cc3468ba0..67fafc53b8e8 100644 --- a/selfservice/strategy/link/strategy_verification_test.go +++ b/selfservice/strategy/link/strategy_verification_test.go @@ -167,6 +167,11 @@ func TestVerification(t *testing.T) { }) t.Run("description=should try to verify an email that does not exist", func(t *testing.T) { + conf.Set(ctx, config.ViperKeySelfServiceVerificationNotifyUnknownRecipients, true) + + t.Cleanup(func() { + conf.Set(ctx, config.ViperKeySelfServiceVerificationNotifyUnknownRecipients, false) + }) var email string var check = func(t *testing.T, actual string) { assert.EqualValues(t, string(node.LinkGroup), gjson.Get(actual, "active").String(), "%s", actual) diff --git a/test/e2e/cypress/integration/profiles/mfa/totp.spec.ts b/test/e2e/cypress/integration/profiles/mfa/totp.spec.ts index ad1fbc67ac9e..2a9e5001b810 100644 --- a/test/e2e/cypress/integration/profiles/mfa/totp.spec.ts +++ b/test/e2e/cypress/integration/profiles/mfa/totp.spec.ts @@ -1,10 +1,10 @@ // Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { gen, website } from "../../../helpers" import { authenticator } from "otplib" -import { routes as react } from "../../../helpers/react" +import { gen, website } from "../../../helpers" import { routes as express } from "../../../helpers/express" +import { routes as react } from "../../../helpers/react" context("2FA TOTP", () => { ;[ @@ -292,7 +292,7 @@ context("2FA TOTP", () => { // The React app keeps using the same flow. The following scenario used to be broken, // because the internal context wasn't populated properly in the flow after settings were saved. - it.only("should allow changing other settings and then setting up totp", () => { + it("should allow changing other settings and then setting up totp", () => { cy.visit(settings) cy.get('input[name="traits.website"]') .clear() diff --git a/test/e2e/cypress/integration/profiles/recovery/code/errors.spec.ts b/test/e2e/cypress/integration/profiles/recovery/code/errors.spec.ts index 61329bf9d5c7..3aff9794046a 100644 --- a/test/e2e/cypress/integration/profiles/recovery/code/errors.spec.ts +++ b/test/e2e/cypress/integration/profiles/recovery/code/errors.spec.ts @@ -1,9 +1,9 @@ // Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { extractRecoveryCode, appPrefix, gen, email } from "../../../../helpers" -import { routes as react } from "../../../../helpers/react" +import { appPrefix, email, extractRecoveryCode, gen } from "../../../../helpers" import { routes as express } from "../../../../helpers/express" +import { routes as react } from "../../../../helpers/react" context("Account Recovery Errors", () => { ;[ @@ -32,6 +32,7 @@ context("Account Recovery Errors", () => { cy.disableVerification() cy.enableRecovery() cy.useRecoveryStrategy("code") + cy.notifyUnknownRecipients("recovery", false) }) it("should invalidate flow if wrong code is submitted too often", () => { @@ -42,7 +43,7 @@ context("Account Recovery Errors", () => { cy.get("button[value='code']").click() cy.get('[data-testid="ui/message/1060003"]').should( "have.text", - "An email containing a recovery code has been sent to the email address you provided.", + "An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", ) cy.recoveryEmailWithCode({ expect: { email: identity.email, enterCode: false }, @@ -68,7 +69,7 @@ context("Account Recovery Errors", () => { cy.get("button[value='code']").click() cy.get('[data-testid="ui/message/1060003"]').should( "have.text", - "An email containing a recovery code has been sent to the email address you provided.", + "An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", ) cy.recoveryEmailWithCode({ expect: { email: identity.email, enterCode: false }, @@ -96,6 +97,7 @@ context("Account Recovery Errors", () => { }) it("should receive a stub email when recovering a non-existent account", () => { + cy.notifyUnknownRecipients("recovery") cy.visit(recovery) const email = gen.email() @@ -105,7 +107,7 @@ context("Account Recovery Errors", () => { cy.location("pathname").should("eq", "/recovery") cy.get('[data-testid="ui/message/1060003"]').should( "have.text", - "An email containing a recovery code has been sent to the email address you provided.", + "An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", ) cy.get('input[name="code"]').should("be.visible") @@ -139,7 +141,7 @@ context("Account Recovery Errors", () => { cy.get("button[value='code']").click() cy.get('[data-testid="ui/message/1060003"]').should( "have.text", - "An email containing a recovery code has been sent to the email address you provided.", + "An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", ) cy.get("input[name='code']").type("01234567") // Invalid code cy.get("button[value='code']").click() @@ -174,7 +176,7 @@ context("Account Recovery Errors", () => { cy.get("button[value='code']").click() cy.get('[data-testid="ui/message/1060003"]').should( "have.text", - "An email containing a recovery code has been sent to the email address you provided.", + "An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", ) cy.getMail().then((mail) => { @@ -183,13 +185,14 @@ context("Account Recovery Errors", () => { }) it("remote recovery email template (recovery_code_invalid)", () => { + cy.notifyUnknownRecipients("recovery") cy.remoteCourierRecoveryCodeTemplates() cy.visit(recovery) cy.get(appPrefix(app) + "input[name='email']").type(email()) cy.get("button[value='code']").click() cy.get('[data-testid="ui/message/1060003"]').should( "have.text", - "An email containing a recovery code has been sent to the email address you provided.", + "An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", ) cy.getMail().then((mail) => { diff --git a/test/e2e/cypress/integration/profiles/recovery/code/success.spec.ts b/test/e2e/cypress/integration/profiles/recovery/code/success.spec.ts index 7d55e66446fc..f4abe4e41890 100644 --- a/test/e2e/cypress/integration/profiles/recovery/code/success.spec.ts +++ b/test/e2e/cypress/integration/profiles/recovery/code/success.spec.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { appPrefix, assertRecoveryAddress, gen } from "../../../../helpers" -import { routes as react } from "../../../../helpers/react" import { routes as express } from "../../../../helpers/express" +import { routes as react } from "../../../../helpers/react" context("Account Recovery With Code Success", () => { ;[ @@ -36,6 +36,7 @@ context("Account Recovery With Code Success", () => { cy.disableVerification() cy.enableRecovery() cy.useRecoveryStrategy("code") + cy.notifyUnknownRecipients("recovery", false) identity = gen.identityWithWebsite() cy.registerApi(identity) @@ -53,7 +54,7 @@ context("Account Recovery With Code Success", () => { cy.get("button[value='code']").click() cy.get('[data-testid="ui/message/1060003"]').should( "have.text", - "An email containing a recovery code has been sent to the email address you provided.", + "An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", ) cy.recoveryEmailWithCode({ expect: { email: identity.email } }) @@ -91,7 +92,7 @@ context("Account Recovery With Code Success", () => { cy.get("button[value='code']").click() cy.get('[data-testid="ui/message/1060003"]').should( "have.text", - "An email containing a recovery code has been sent to the email address you provided.", + "An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", ) cy.get("input[name='code']").type("12312312") // Invalid code cy.get("button[value='code']").click() @@ -123,7 +124,7 @@ context("Account Recovery With Code Success", () => { cy.get("button[value='code']").click() cy.get('[data-testid="ui/message/1060003"]').should( "have.text", - "An email containing a recovery code has been sent to the email address you provided.", + "An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", ) cy.recoveryEmailWithCode({ @@ -149,6 +150,17 @@ context("Account Recovery With Code Success", () => { identity.email, ) }) + it("should not notify an unknown recipient", () => { + const recipient = gen.email() + + cy.visit(recovery) + cy.get('input[name="email"]').type(recipient) + cy.get(`[name="method"][value="code"]`).click() + + cy.getCourierMessages().then((messages) => { + expect(messages.map((msg) => msg.recipient)).to.not.include(recipient) + }) + }) }) }) @@ -172,7 +184,7 @@ context("Account Recovery With Code Success", () => { cy.get("button[value='code']").click() cy.get('[data-testid="ui/message/1060003"]').should( "have.text", - "An email containing a recovery code has been sent to the email address you provided.", + "An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", ) cy.recoveryEmailWithCode({ expect: { email: identity.email } }) diff --git a/test/e2e/cypress/integration/profiles/recovery/link/errors.spec.ts b/test/e2e/cypress/integration/profiles/recovery/link/errors.spec.ts index 2a305dd42330..e59e6e37207c 100644 --- a/test/e2e/cypress/integration/profiles/recovery/link/errors.spec.ts +++ b/test/e2e/cypress/integration/profiles/recovery/link/errors.spec.ts @@ -1,9 +1,9 @@ // Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { APP_URL, appPrefix, gen, parseHtml } from "../../../../helpers" -import { routes as react } from "../../../../helpers/react" +import { appPrefix, gen, parseHtml } from "../../../../helpers" import { routes as express } from "../../../../helpers/express" +import { routes as react } from "../../../../helpers/react" context("Account Recovery Errors", () => { ;[ @@ -34,6 +34,7 @@ context("Account Recovery Errors", () => { cy.useRecoveryStrategy("link") cy.disableRecoveryStrategy("code") cy.clearAllCookies() + cy.notifyUnknownRecipients("verification", false) }) it("responds with a HTML response on link click of an API flow if the link is expired", () => { @@ -85,6 +86,7 @@ context("Account Recovery Errors", () => { }) it("should receive a stub email when recovering a non-existent account", () => { + cy.notifyUnknownRecipients("recovery") cy.visit(recovery) const email = gen.email() @@ -94,7 +96,7 @@ context("Account Recovery Errors", () => { cy.location("pathname").should("eq", "/recovery") cy.get('[data-testid="ui/message/1060002"]').should( "have.text", - "An email containing a recovery link has been sent to the email address you provided.", + "An email containing a recovery link has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", ) cy.get('input[name="email"]').should("have.value", email) @@ -204,6 +206,7 @@ context("Account Recovery Errors", () => { }) it("invalid remote recovery email template", () => { + cy.notifyUnknownRecipients("recovery") cy.remoteCourierRecoveryTemplates() const identity = gen.identityWithWebsite() cy.recoverApi({ email: identity.email }) diff --git a/test/e2e/cypress/integration/profiles/recovery/link/success.spec.ts b/test/e2e/cypress/integration/profiles/recovery/link/success.spec.ts index fae20ada1fab..8dec47a474de 100644 --- a/test/e2e/cypress/integration/profiles/recovery/link/success.spec.ts +++ b/test/e2e/cypress/integration/profiles/recovery/link/success.spec.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { appPrefix, assertRecoveryAddress, gen } from "../../../../helpers" -import { routes as react } from "../../../../helpers/react" import { routes as express } from "../../../../helpers/express" +import { routes as react } from "../../../../helpers/react" context("Account Recovery Success", () => { ;[ @@ -70,6 +70,18 @@ context("Account Recovery Success", () => { cookieUrl: base, }) }) + + it("should not notify an unknown recipient", () => { + const recipient = gen.email() + + cy.visit(recovery) + cy.get('input[name="email"]').type(recipient) + cy.get(`[name="method"][value="link"]`).click() + + cy.getCourierMessages().then((messages) => { + expect(messages.map((msg) => msg.recipient)).to.not.include(recipient) + }) + }) }) }) diff --git a/test/e2e/cypress/integration/profiles/verification/verify/errors.spec.ts b/test/e2e/cypress/integration/profiles/verification/verify/errors.spec.ts index ccfc9af1e12a..85588e075072 100644 --- a/test/e2e/cypress/integration/profiles/verification/verify/errors.spec.ts +++ b/test/e2e/cypress/integration/profiles/verification/verify/errors.spec.ts @@ -41,6 +41,8 @@ context("Account Verification Error", () => { cy.longVerificationLifespan() cy.longLifespan(s) cy.useVerificationStrategy(s) + cy.resetCourierTemplates("verification") + cy.notifyUnknownRecipients("verification", false) identity = gen.identity() cy.registerApi(identity) @@ -138,12 +140,15 @@ context("Account Verification Error", () => { }) }) - it("unable to verify non-existent account", async () => { - cy.get('input[name="email"]').type(gen.identity().email) + it("unable to verify non-existent account", () => { + cy.notifyUnknownRecipients("verification") + const email = gen.identity().email + cy.get('input[name="email"]').type(email) cy.get(`button[value="${s}"]`).click() cy.getMail().then((mail) => { + expect(mail.toAddresses).includes(email) expect(mail.subject).eq( - "Someone tried to verify this email address (remote)", + "Someone tried to verify this email address", ) }) }) diff --git a/test/e2e/cypress/integration/profiles/verification/verify/success.spec.ts b/test/e2e/cypress/integration/profiles/verification/verify/success.spec.ts index c5cc343282a2..c38b28526e73 100644 --- a/test/e2e/cypress/integration/profiles/verification/verify/success.spec.ts +++ b/test/e2e/cypress/integration/profiles/verification/verify/success.spec.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { APP_URL, assertVerifiableAddress, gen } from "../../../../helpers" -import { routes as react } from "../../../../helpers/react" import { routes as express } from "../../../../helpers/express" +import { routes as react } from "../../../../helpers/react" import { Strategy } from "../../../../support" context("Account Verification Settings Success", () => { @@ -32,6 +32,7 @@ context("Account Verification Settings Success", () => { beforeEach(() => { cy.useVerificationStrategy(s) + cy.notifyUnknownRecipients("verification", false) identity = gen.identity() cy.register(identity) cy.deleteMail({ atLeast: 1 }) // clean up registration email @@ -52,6 +53,7 @@ context("Account Verification Settings Success", () => { }) it("should request verification for an email that does not exist yet", () => { + cy.notifyUnknownRecipients("verification") const email = `not-${identity.email}` cy.get('input[name="email"]').type(email) cy.get(`button[value="${s}"]`).click() @@ -136,6 +138,20 @@ context("Account Verification Settings Success", () => { strategy: s, }) }) + + it.only("should not notify an unknown recipient", () => { + const recipient = gen.email() + + cy.visit(APP_URL + "/self-service/verification/browser") + cy.get('input[name="email"]').type(recipient) + cy.get(`[name="method"][value="${s}"]`).click() + + cy.getCourierMessages().then((messages) => { + expect(messages.map((msg) => msg.recipient)).to.not.include( + recipient, + ) + }) + }) }) } }) diff --git a/test/e2e/cypress/support/commands.ts b/test/e2e/cypress/support/commands.ts index 6553f79fb65e..597f9cae82f8 100644 --- a/test/e2e/cypress/support/commands.ts +++ b/test/e2e/cypress/support/commands.ts @@ -738,23 +738,8 @@ Cypress.Commands.add("remoteCourierRecoveryCodeTemplates", ({} = {}) => { Cypress.Commands.add("resetCourierTemplates", (type) => { updateConfigFile((config) => { - config.courier.templates = { - [type]: { - invalid: { - email: { - body: {}, - subject: "", - }, - }, - valid: { - email: { - body: { - body: {}, - subject: "", - }, - }, - }, - }, + if (config?.courier?.templates && type in config.courier.templates) { + delete config.courier.templates[type] } return config }) @@ -1347,3 +1332,19 @@ Cypress.Commands.add( }) }, ) + +Cypress.Commands.add( + "notifyUnknownRecipients", + (flow: "recovery" | "verification", value: boolean = true) => { + cy.updateConfigFile((config) => { + config.selfservice.flows[flow].notify_unknown_recipients = value + return config + }) + }, +) + +Cypress.Commands.add("getCourierMessages", () => { + return cy.request(KRATOS_ADMIN + "/courier/messages").then((res) => { + return res.body + }) +}) diff --git a/test/e2e/cypress/support/index.d.ts b/test/e2e/cypress/support/index.d.ts index d65b3f095b5b..a119ff716819 100644 --- a/test/e2e/cypress/support/index.d.ts +++ b/test/e2e/cypress/support/index.d.ts @@ -571,6 +571,17 @@ declare global { */ enableLoginForVerifiedAddressOnly(): Chainable + /** + * Sets the value for the `notify_unknown_recipients` key for a flow + * + * @param flow the flow for which to set the config value + * @param value the value, defaults to true + */ + notifyUnknownRecipients( + flow: "recovery" | "verification", + value?: boolean, + ): Chainable + /** * Sign a user in via the API and return the session. * @@ -619,6 +630,10 @@ declare global { * Remove the specified attribute from the given HTML elements */ removeAttribute(selectors: string[], attribute: string): Chainable + + getCourierMessages(): Chainable< + { recipient: string; template_type: string }[] + > } } } diff --git a/text/message_recovery.go b/text/message_recovery.go index 3ab099dc4975..788b88f2808b 100644 --- a/text/message_recovery.go +++ b/text/message_recovery.go @@ -35,7 +35,7 @@ func NewRecoveryEmailSent() *Message { return &Message{ ID: InfoSelfServiceRecoveryEmailSent, Type: Info, - Text: "An email containing a recovery link has been sent to the email address you provided.", + Text: "An email containing a recovery link has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", Context: context(nil), } } @@ -44,7 +44,7 @@ func NewRecoveryEmailWithCodeSent() *Message { return &Message{ ID: InfoSelfServiceRecoveryEmailWithCodeSent, Type: Info, - Text: "An email containing a recovery code has been sent to the email address you provided.", + Text: "An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", Context: context(nil), } } diff --git a/text/message_verification.go b/text/message_verification.go index cb4136631e59..6719f7ee9436 100644 --- a/text/message_verification.go +++ b/text/message_verification.go @@ -31,7 +31,7 @@ func NewVerificationEmailSent() *Message { return &Message{ ID: InfoSelfServiceVerificationEmailSent, Type: Info, - Text: "An email containing a verification link has been sent to the email address you provided.", + Text: "An email containing a verification link has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", Context: context(nil), } } @@ -76,7 +76,7 @@ func NewVerificationEmailWithCodeSent() *Message { return &Message{ ID: InfoSelfServiceVerificationEmailWithCodeSent, Type: Info, - Text: "An email containing a verification code has been sent to the email address you provided.", + Text: "An email containing a verification code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.", Context: context(nil), } }