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
540 lines
18 KiB
Go
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
|
|
}
|