// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package code import ( "context" "net/http" "net/url" "strings" "github.com/pkg/errors" "github.com/ory/herodot" "github.com/ory/kratos/courier" "github.com/ory/kratos/courier/template" "github.com/ory/kratos/courier/template/email" "github.com/ory/kratos/courier/template/sms" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/hydra" "github.com/ory/kratos/identity" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/recovery" "github.com/ory/kratos/selfservice/flow/verification" "github.com/ory/kratos/selfservice/hook" "github.com/ory/kratos/x" "github.com/ory/x/httpx" "github.com/ory/x/logrusx" "github.com/ory/x/sqlcon" "github.com/ory/x/stringsx" "github.com/ory/x/urlx" ) type ( senderDependencies interface { courier.Provider courier.ConfigProvider identity.PoolProvider identity.ManagementProvider identity.PrivilegedPoolProvider logrusx.Provider config.Provider RecoveryCodePersistenceProvider VerificationCodePersistenceProvider RegistrationCodePersistenceProvider LoginCodePersistenceProvider hydra.Provider httpx.ClientProvider } SenderProvider interface { CodeSender() *Sender } Sender struct { deps senderDependencies } Address struct { To string Via identity.CodeChannel } ) func ErrUnknownAddress() *herodot.DefaultError { return herodot.ErrNotFound().WithReason("recovery requested for unknown address") } func NewSender(deps senderDependencies) *Sender { return &Sender{deps: deps} } func (s *Sender) SendCode(ctx context.Context, f flow.Flow, id *identity.Identity, header http.Header, addresses ...Address) error { s.deps.Logger(). WithSensitiveField("address", addresses). Debugf("Preparing %s code", f.GetFlowName()) transientPayload, err := x.ParseRawMessageOrEmpty(f.GetTransientPayload()) if err != nil { return errors.WithStack(err) } // If the flow was initiated by an OAuth2 login challenge, expose a scoped // view of the OAuth2 client to the courier templates so that messages can // be branded per client. var oauth2LoginRequest *template.OAuth2LoginRequest if p, ok := f.(flow.HydraLoginRequestProvider); ok { oauth2LoginRequest = template.NewOAuth2LoginRequest(p.GetHydraLoginRequest()) } // send to all addresses for _, address := range addresses { // We have to generate a unique code per address, or otherwise it is not possible to link which // address was used to verify the code. // // See also [this discussion](https://github.com/ory/kratos/pull/3456#discussion_r1307560988). rawCode := GenerateCode() switch f.GetFlowName() { case flow.RegistrationFlow: code, err := s.deps. RegistrationCodePersister(). CreateRegistrationCode(ctx, &CreateRegistrationCodeParams{ AddressType: address.Via, RawCode: rawCode, ExpiresIn: s.deps.Config().SelfServiceCodeMethodLifespan(ctx), FlowID: f.GetID(), Address: address.To, }) if err != nil { return err } model, err := x.StructToMap(id.Traits) if err != nil { return err } s.deps.Logger(). WithField("registration_flow_id", code.FlowID). WithField("registration_code_id", code.ID). WithSensitiveField("registration_code", rawCode). Info("Sending out registration email with code.") var t courier.Template switch address.Via { case identity.ChannelTypeEmail: t = email.NewRegistrationCodeValid(s.deps, &email.RegistrationCodeValidModel{ To: address.To, RegistrationCode: rawCode, Traits: model, RequestURL: f.GetRequestURL(), TransientPayload: transientPayload, ExpiresInMinutes: int(s.deps.Config().SelfServiceCodeMethodLifespan(ctx).Minutes()), OAuth2LoginRequest: oauth2LoginRequest, UserRequestHeaders: hook.RemoveDisallowedHeaders(header, s.deps.Config().WebhookHeaderAllowlist(ctx)), }) case identity.ChannelTypeSMS: t = sms.NewRegistrationCodeValid(s.deps, &sms.RegistrationCodeValidModel{ To: address.To, RegistrationCode: rawCode, Identity: model, RequestURL: f.GetRequestURL(), RequestURLDomain: requestURLDomain(f.GetRequestURL()), TransientPayload: transientPayload, ExpiresInMinutes: int(s.deps.Config().SelfServiceCodeMethodLifespan(ctx).Minutes()), OAuth2LoginRequest: oauth2LoginRequest, UserRequestHeaders: hook.RemoveDisallowedHeaders(header, s.deps.Config().WebhookHeaderAllowlist(ctx)), }) } if err := s.send(ctx, string(address.Via), t); err != nil { return errors.WithStack(err) } case flow.LoginFlow: code, err := s.deps. LoginCodePersister(). CreateLoginCode(ctx, &CreateLoginCodeParams{ AddressType: address.Via, Address: address.To, RawCode: rawCode, ExpiresIn: s.deps.Config().SelfServiceCodeMethodLifespan(ctx), FlowID: f.GetID(), IdentityID: id.ID, IdentityRegion: id.Region, }) if err != nil { return err } model, err := x.StructToMap(id) if err != nil { return err } s.deps.Logger(). WithField("login_flow_id", code.FlowID). WithField("login_code_id", code.ID). WithSensitiveField("login_code", rawCode). Info("Sending out login email with code.") var t courier.Template switch address.Via { case identity.ChannelTypeEmail: t = email.NewLoginCodeValid(s.deps, &email.LoginCodeValidModel{ To: address.To, LoginCode: rawCode, Identity: model, RequestURL: f.GetRequestURL(), TransientPayload: transientPayload, ExpiresInMinutes: int(s.deps.Config().SelfServiceCodeMethodLifespan(ctx).Minutes()), OAuth2LoginRequest: oauth2LoginRequest, UserRequestHeaders: hook.RemoveDisallowedHeaders(header, s.deps.Config().WebhookHeaderAllowlist(ctx)), }) case identity.ChannelTypeSMS: t = sms.NewLoginCodeValid(s.deps, &sms.LoginCodeValidModel{ To: address.To, LoginCode: rawCode, Identity: model, RequestURL: f.GetRequestURL(), RequestURLDomain: requestURLDomain(f.GetRequestURL()), TransientPayload: transientPayload, ExpiresInMinutes: int(s.deps.Config().SelfServiceCodeMethodLifespan(ctx).Minutes()), OAuth2LoginRequest: oauth2LoginRequest, UserRequestHeaders: hook.RemoveDisallowedHeaders(header, s.deps.Config().WebhookHeaderAllowlist(ctx)), }) } if err := s.send(ctx, string(address.Via), t); err != nil { return errors.WithStack(err) } default: return errors.WithStack(errors.New("received unknown flow type")) } } return nil } // 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, to string, requestHeader http.Header) error { s.deps.Logger(). WithField("via", via). WithSensitiveField("address", to). Debug("Preparing recovery code.") address, err := s.deps.IdentityPool().FindRecoveryAddressByValue(ctx, via, to) if errors.Is(err, sqlcon.ErrNoRows()) { notifyUnknownRecipients := s.deps.Config().SelfServiceFlowRecoveryNotifyUnknownRecipients(ctx) s.deps.Logger(). WithField("via", via). WithSensitiveField("address", to). WithField("strategy", "code"). WithField("was_notified", notifyUnknownRecipients). Info("Account recovery was requested for an unknown address.") transientPayload, err := x.ParseRawMessageOrEmpty(f.GetTransientPayload()) if err != nil { return errors.WithStack(err) } // We only send a notification if the configuration allows it *and* the channel is email. // That's because we pay per SMS sent (typically) so we want to avoid that, contrary to email. shouldNotifyOfUnkownRecipient := notifyUnknownRecipients && via == identity.AddressTypeEmail if !shouldNotifyOfUnkownRecipient { // do nothing } else if err := s.send(ctx, via, email.NewRecoveryCodeInvalid(s.deps, &email.RecoveryCodeInvalidModel{ To: to, RequestURL: f.RequestURL, TransientPayload: transientPayload, })); err != nil { return err } return errors.WithStack(ErrUnknownAddress()) } else if err != nil { // DB error return err } // Get the identity associated with the recovery address i, err := s.deps.IdentityPool().GetIdentity(ctx, address.IdentityID, identity.ExpandDefault) if err != nil { return err } rawCode := GenerateCode() var code *RecoveryCode if code, err = s.deps. RecoveryCodePersister(). CreateRecoveryCode(ctx, &CreateRecoveryCodeParams{ RawCode: rawCode, CodeType: RecoveryCodeTypeSelfService, ExpiresIn: s.deps.Config().SelfServiceCodeMethodLifespan(ctx), RecoveryAddress: address, FlowID: f.ID, IdentityID: i.ID, }); err != nil { return err } return s.SendRecoveryCodeTo(ctx, i, rawCode, code, f, requestHeader) } func (s *Sender) SendRecoveryCodeTo(ctx context.Context, i *identity.Identity, codeString string, code *RecoveryCode, f *recovery.Flow, requestHeader http.Header) error { s.deps.Logger(). WithField("via", code.RecoveryAddress.Via). WithField("identity_id", code.RecoveryAddress.IdentityID). WithField("recovery_code_id", code.ID). WithSensitiveField("address", code.RecoveryAddress.Value). WithSensitiveField("recovery_code", codeString). Info("Sending out recovery code.") model, err := x.StructToMap(i) if err != nil { return err } transientPayload, err := x.ParseRawMessageOrEmpty(f.GetTransientPayload()) if err != nil { return errors.WithStack(err) } var t courier.Template switch code.RecoveryAddress.Via { case identity.AddressTypeEmail: t = email.NewRecoveryCodeValid(s.deps, &email.RecoveryCodeValidModel{ To: code.RecoveryAddress.Value, RecoveryCode: codeString, Identity: model, RequestURL: f.GetRequestURL(), TransientPayload: transientPayload, ExpiresInMinutes: int(s.deps.Config().SelfServiceCodeMethodLifespan(ctx).Minutes()), UserRequestHeaders: hook.RemoveDisallowedHeaders(requestHeader, s.deps.Config().WebhookHeaderAllowlist(ctx)), }) case identity.AddressTypeSMS: u, err := url.Parse(f.GetRequestURL()) if err != nil { return err } t = sms.NewRecoveryCodeValid(s.deps, &sms.RecoveryCodeValidModel{ To: code.RecoveryAddress.Value, RecoveryCode: codeString, Identity: model, RequestURL: f.GetRequestURL(), RequestURLDomain: u.Hostname(), TransientPayload: transientPayload, ExpiresInMinutes: int(s.deps.Config().SelfServiceCodeMethodLifespan(ctx).Minutes()), UserRequestHeaders: hook.RemoveDisallowedHeaders(requestHeader, s.deps.Config().WebhookHeaderAllowlist(ctx)), }) default: return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Expected email or sms but got %s", code.RecoveryAddress.Via)) } return s.send(ctx, code.RecoveryAddress.Via, t) } // 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 string, to string) error { s.deps.Logger(). WithField("via", via). WithSensitiveField("address", to). Debug("Preparing verification code.") address, err := s.deps.IdentityPool().FindVerifiableAddressByValue(ctx, via, to) if errors.Is(err, sqlcon.ErrNoRows()) { notifyUnknownRecipients := s.deps.Config().SelfServiceFlowVerificationNotifyUnknownRecipients(ctx) s.deps.Logger(). WithField("via", via). WithField("strategy", "code"). WithSensitiveField("email_address", to). WithField("was_notified", notifyUnknownRecipients). Info("Address verification was requested for an unknown address.") transientPayload, err := x.ParseRawMessageOrEmpty(f.GetTransientPayload()) if err != nil { return errors.WithStack(err) } if !notifyUnknownRecipients || via != identity.AddressTypeEmail { // Only send unknown-address notifications via email. SMS costs money per // message, so we skip notification for unknown phone numbers. } else if err := s.send(ctx, via, email.NewVerificationCodeInvalid(s.deps, &email.VerificationCodeInvalidModel{ To: to, RequestURL: f.GetRequestURL(), TransientPayload: transientPayload, })); err != nil { return err } return errors.WithStack(ErrUnknownAddress()) } else if err != nil { return err } rawCode := GenerateCode() if _, err = s.deps.VerificationCodePersister().CreateVerificationCode(ctx, &CreateVerificationCodeParams{ RawCode: rawCode, ExpiresIn: s.deps.Config().SelfServiceCodeMethodLifespan(ctx), VerifiableAddress: address, FlowID: f.ID, }); err != nil { return err } // Get the identity associated with the recovery address i, err := s.deps.IdentityPool().GetIdentity(ctx, address.IdentityID, identity.ExpandDefault) if err != nil { return err } if err := s.SendVerificationCodeTo(ctx, f, i, rawCode, address); err != nil { return err } address.Status = identity.VerifiableAddressStatusSent return s.deps.PrivilegedIdentityPool().UpdateVerifiableAddress(ctx, address, "status") } func (s *Sender) constructVerificationLink(ctx context.Context, f *verification.Flow, codeStr string) string { return urlx.CopyWithQuery( urlx.AppendPaths(x.CourierBaseURL(f.GetCourierBaseURL(), s.deps.Config().SelfPublicURL(ctx)), verification.RouteSubmitFlow), url.Values{ "flow": {f.ID.String()}, "code": {codeStr}, }).String() } func (s *Sender) SendVerificationCodeTo(ctx context.Context, f *verification.Flow, i *identity.Identity, codeString string, address identity.VerifiableAddressLike) error { to, via := address.Address(), address.DeliveryVia() s.deps.Logger(). WithField("via", via). WithField("identity_id", i.ID). WithSensitiveField("email_address", to). WithSensitiveField("verification_link_token", codeString). Info("Sending out verification email with verification code.") model, err := x.StructToMap(i) if err != nil { return err } transientPayload, err := x.ParseRawMessageOrEmpty(f.GetTransientPayload()) if err != nil { return errors.WithStack(err) } // If the flow was initiated by an OAuth2 login challenge, expose a scoped // view of the OAuth2 client to the courier templates so that messages can // be branded per client. if f.OAuth2LoginChallenge != "" && f.HydraLoginRequest == nil { hlr, err := s.deps.Hydra().GetLoginRequest(ctx, string(f.OAuth2LoginChallenge)) if err != nil { return errors.WithStack(err) } f.HydraLoginRequest = hlr } oauth2LoginRequest := template.NewOAuth2LoginRequest(f.HydraLoginRequest) var t courier.Template // TODO: this can likely be abstracted by making templates not specific to the channel they're using switch via { case identity.ChannelTypeEmail: t = email.NewVerificationCodeValid(s.deps, &email.VerificationCodeValidModel{ To: to, VerificationURL: s.constructVerificationLink(ctx, f, codeString), Identity: model, VerificationCode: codeString, RequestURL: f.GetRequestURL(), TransientPayload: transientPayload, ExpiresInMinutes: int(s.deps.Config().SelfServiceCodeMethodLifespan(ctx).Minutes()), OAuth2LoginRequest: oauth2LoginRequest, }) case identity.ChannelTypeSMS: t = sms.NewVerificationCodeValid(s.deps, &sms.VerificationCodeValidModel{ To: to, VerificationCode: codeString, Identity: model, RequestURL: f.GetRequestURL(), RequestURLDomain: requestURLDomain(f.GetRequestURL()), TransientPayload: transientPayload, ExpiresInMinutes: int(s.deps.Config().SelfServiceCodeMethodLifespan(ctx).Minutes()), OAuth2LoginRequest: oauth2LoginRequest, }) default: return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Expected email or sms but got %s", via)) } return s.send(ctx, via, t) } func (s *Sender) send(ctx context.Context, via string, t courier.Template) error { switch f := stringsx.SwitchExact(via); { case f.AddCase(identity.ChannelTypeEmail): c, err := s.deps.Courier(ctx) if err != nil { return err } t, ok := t.(courier.EmailTemplate) if !ok { return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Expected email template but got %T", t)) } _, err = c.QueueEmail(ctx, t) return err case f.AddCase(identity.ChannelTypeSMS): c, err := s.deps.Courier(ctx) if err != nil { return err } t, ok := t.(courier.SMSTemplate) if !ok { return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Expected sms template but got %T", t)) } _, err = c.QueueSMS(ctx, t) return err default: return f.ToUnknownCaseErr() } } // requestURLDomain returns the bare hostname of the flow's request URL. It is // used to bind the SMS one-time code to its origin for the Web OTP API. The // result is a best-effort hint: a malformed URL yields an empty string and must // not block delivery of the code. func requestURLDomain(rawURL string) string { u, err := url.Parse(rawURL) if err != nil { return "" } return u.Hostname() } // hackyInferChannel infers the channel (email or sms) based on the address format. // Once we support arbitrary custom channels, we need to refactor the recovery/verification // flow to first look up the address, and let the user select the channel if multiple are available. func hackyInferChannel(addr string) string { // Inferring the address type like this is a bit hacky, and actually not really necessary. // That's because `SendRecoveryCode` expects it, but not because it fundamentally is required. if strings.ContainsRune(addr, '@') { return identity.AddressTypeEmail } return identity.AddressTypeSMS }