Files
upstream-ory-kratos/selfservice/strategy/code/code_sender.go
upstream-mirror c60cc7ca75
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

540 lines
18 KiB
Go

// 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
}