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
335 lines
11 KiB
Go
335 lines
11 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package recovery
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
"go.opentelemetry.io/otel/trace"
|
|
|
|
"github.com/ory/kratos/x/redir"
|
|
|
|
"github.com/ory/pop/v6"
|
|
|
|
"github.com/gofrs/uuid"
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/ory/kratos/driver/config"
|
|
"github.com/ory/kratos/selfservice/flow"
|
|
"github.com/ory/kratos/ui/container"
|
|
"github.com/ory/kratos/x"
|
|
"github.com/ory/x/clock"
|
|
"github.com/ory/x/otelx/semconv"
|
|
"github.com/ory/x/sqlxx"
|
|
"github.com/ory/x/urlx"
|
|
)
|
|
|
|
// A Recovery Flow
|
|
//
|
|
// This request is used when an identity wants to recover their account.
|
|
//
|
|
// We recommend reading the [Account Recovery Documentation](../self-service/flows/password-reset-account-recovery)
|
|
//
|
|
// swagger:model recoveryFlow
|
|
type Flow struct {
|
|
// ID represents the request's unique ID. When performing the recovery flow, this
|
|
// represents the id in the recovery ui's query parameter: http://<selfservice.flows.recovery.ui_url>?request=<id>
|
|
//
|
|
// required: true
|
|
// type: string
|
|
// format: uuid
|
|
ID uuid.UUID `json:"id" db:"id" faker:"-"`
|
|
|
|
// Type represents the flow's type which can be either "api" or "browser", depending on the flow interaction.
|
|
//
|
|
// required: true
|
|
Type flow.Type `json:"type" db:"type" faker:"flow_type"`
|
|
|
|
// ExpiresAt is the time (UTC) when the request expires. If the user still wishes to update the setting,
|
|
// a new request has to be initiated.
|
|
//
|
|
// required: true
|
|
ExpiresAt time.Time `json:"expires_at" faker:"time_type" db:"expires_at"`
|
|
|
|
// IssuedAt is the time (UTC) when the request occurred.
|
|
//
|
|
// required: true
|
|
IssuedAt time.Time `json:"issued_at" faker:"time_type" db:"issued_at"`
|
|
|
|
// RequestURL is the initial URL that was requested from Ory Kratos. It can be used
|
|
// to forward information contained in the URL's path or query for example.
|
|
//
|
|
// required: true
|
|
RequestURL string `json:"request_url" db:"request_url"`
|
|
|
|
// InternalContext stores internal-only data for this flow.
|
|
InternalContext sqlxx.JSONRawMessage `db:"internal_context" json:"-" faker:"-"`
|
|
|
|
// ReturnTo contains the requested return_to URL.
|
|
ReturnTo string `json:"return_to,omitempty" db:"-"`
|
|
|
|
// Active, if set, contains the recovery method that is being used. It is initially
|
|
// not set.
|
|
Active sqlxx.NullString `json:"active,omitempty" faker:"-" db:"active_method"`
|
|
|
|
// UI contains data which must be shown in the user interface.
|
|
//
|
|
// required: true
|
|
UI *container.Container `json:"ui" db:"ui"`
|
|
|
|
// State represents the state of this request:
|
|
//
|
|
// - choose_method: ask the user to choose a method (e.g. recover account via email)
|
|
// - sent_email: the email has been sent to the user
|
|
// - passed_challenge: the request was successful and the recovery challenge was passed.
|
|
//
|
|
// required: true
|
|
State State `json:"state" faker:"-" db:"state"`
|
|
|
|
// CSRFToken contains the anti-csrf token associated with this request.
|
|
CSRFToken string `json:"-" db:"csrf_token"`
|
|
|
|
// CreatedAt is a helper struct field for gobuffalo.pop.
|
|
CreatedAt time.Time `json:"-" faker:"-" db:"created_at"`
|
|
|
|
// UpdatedAt is a helper struct field for gobuffalo.pop.
|
|
UpdatedAt time.Time `json:"-" faker:"-" db:"updated_at"`
|
|
|
|
// RecoveredIdentityID is a helper struct field for gobuffalo.pop.
|
|
RecoveredIdentityID uuid.NullUUID `json:"-" faker:"-" db:"recovered_identity_id"`
|
|
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
|
|
|
|
// DangerousSkipCSRFCheck indicates whether anti CSRF measures should be enforced in this flow
|
|
//
|
|
// This is needed, because we can not enforce these measures, if the flow has been initialized by someone else than
|
|
// the user.
|
|
DangerousSkipCSRFCheck bool `json:"-" faker:"-" db:"skip_csrf_check"`
|
|
|
|
// Contains possible actions that could follow this flow
|
|
ContinueWith []flow.ContinueWith `json:"continue_with,omitempty" faker:"-" db:"-"`
|
|
|
|
// TransientPayload is used to pass data from the recovery flow to hooks and email templates
|
|
//
|
|
// required: false
|
|
TransientPayload json.RawMessage `json:"transient_payload,omitempty" faker:"-" db:"-"`
|
|
}
|
|
|
|
var _ flow.Flow = (*Flow)(nil)
|
|
|
|
// flowDependencies are the dependencies NewFlow needs to construct a recovery
|
|
// flow: the configuration (for return-to validation) and the clock. The
|
|
// lifespan and CSRF token are passed explicitly because callers vary them
|
|
// (token-based lifespans, regenerated CSRF tokens).
|
|
type flowDependencies interface {
|
|
config.Provider
|
|
clock.Provider
|
|
}
|
|
|
|
func NewFlow(reg flowDependencies, exp time.Duration, csrf string, r *http.Request, strategies Strategies, ft flow.Type) (*Flow, error) {
|
|
conf := reg.Config()
|
|
now := reg.Clock().Now().UTC()
|
|
id := x.NewUUID()
|
|
|
|
// Pre-validate the return to URL which is contained in the HTTP request.
|
|
requestURL := x.RequestURL(r).String()
|
|
_, err := redir.SecureRedirectTo(r,
|
|
conf.SelfServiceBrowserDefaultReturnTo(r.Context()),
|
|
redir.SecureRedirectUseSourceURL(requestURL),
|
|
redir.SecureRedirectAllowURLs(conf.SelfServiceBrowserAllowedReturnToDomains(r.Context())),
|
|
redir.SecureRedirectAllowSelfServiceURLs(conf.SelfPublicURL(r.Context())),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
state := flow.StateChooseMethod
|
|
if conf.ChooseRecoveryAddress(r.Context()) {
|
|
state = flow.StateRecoveryAwaitingAddress
|
|
} else {
|
|
trace.SpanFromContext(r.Context()).AddEvent(semconv.NewDeprecatedFeatureUsedEvent(r.Context(), "legacy_recovery_flow"))
|
|
}
|
|
|
|
f := &Flow{
|
|
ID: id,
|
|
ExpiresAt: now.Add(exp),
|
|
IssuedAt: now,
|
|
RequestURL: requestURL,
|
|
UI: &container.Container{
|
|
Method: "POST",
|
|
Action: flow.AppendFlowTo(urlx.AppendPaths(conf.SelfPublicURL(r.Context()), RouteSubmitFlow), id).String(),
|
|
},
|
|
State: state,
|
|
CSRFToken: csrf,
|
|
Type: ft,
|
|
}
|
|
if err := f.setCourierBaseURL(x.BaseURLStringFromContext(r.Context())); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, strategy := range strategies {
|
|
if strategy.IsPrimary() {
|
|
f.Active = sqlxx.NullString(strategy.NodeGroup())
|
|
}
|
|
if err := strategy.PopulateRecoveryMethod(r, f); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return f, nil
|
|
}
|
|
|
|
func FromOldFlow(reg flowDependencies, exp time.Duration, csrf string, r *http.Request, strategies Strategies, of Flow) (*Flow, error) {
|
|
f := of.Type
|
|
// Using the same flow in the recovery/verification context can lead to using API flow in a verification/recovery email
|
|
if of.Type == flow.TypeAPI && of.Active.String() == string(RecoveryStrategyLink) {
|
|
f = flow.TypeBrowser
|
|
}
|
|
nf, err := NewFlow(reg, exp, csrf, r, strategies, f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nf.RequestURL = of.RequestURL
|
|
return nf, nil
|
|
}
|
|
|
|
func (f *Flow) GetType() flow.Type { return f.Type }
|
|
func (f *Flow) GetRequestURL() string { return f.RequestURL }
|
|
func (Flow) TableName() string { return "selfservice_recovery_flows" }
|
|
func (f Flow) GetID() uuid.UUID { return f.ID }
|
|
func (f *Flow) GetUI() *container.Container { return f.UI }
|
|
func (f *Flow) GetState() State { return f.State }
|
|
func (Flow) GetFlowName() flow.FlowName { return flow.RecoveryFlow }
|
|
func (f *Flow) SetState(state State) { f.State = state }
|
|
func (f *Flow) GetTransientPayload() json.RawMessage { return f.TransientPayload }
|
|
func (f *Flow) GetInternalContext() sqlxx.JSONRawMessage { return f.InternalContext }
|
|
func (f *Flow) SetInternalContext(c sqlxx.JSONRawMessage) { f.InternalContext = c }
|
|
|
|
// EnsureInternalContext initializes InternalContext to an empty JSON object
|
|
// if it is missing or not valid JSON. Mirrors the registration / settings /
|
|
// login flow implementations.
|
|
func (f *Flow) EnsureInternalContext() {
|
|
if !gjson.ValidBytes(f.InternalContext) {
|
|
f.InternalContext = []byte("{}")
|
|
}
|
|
}
|
|
|
|
// GetCourierBaseURL returns the base URL captured at flow init from the
|
|
// request context, or the empty string when nothing was captured (the email
|
|
// senders then fall back to Config.SelfPublicURL).
|
|
func (f *Flow) GetCourierBaseURL() string {
|
|
return gjson.GetBytes(f.InternalContext, flow.InternalContextKeyCourierBaseURL).String()
|
|
}
|
|
|
|
// setCourierBaseURL writes the captured base URL into InternalContext under
|
|
// the well-known key. Empty input is a no-op (preserves the fall-back
|
|
// path). Inputs longer than 8192 bytes are rejected — the same implicit
|
|
// ceiling the dedicated VARCHAR(8192) column used to enforce — so a
|
|
// pathological header cannot bloat the row.
|
|
func (f *Flow) setCourierBaseURL(s string) error {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
if len(s) > 8192 {
|
|
return nil
|
|
}
|
|
f.EnsureInternalContext()
|
|
out, err := sjson.SetBytes(f.InternalContext, flow.InternalContextKeyCourierBaseURL, s)
|
|
if err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
f.InternalContext = out
|
|
return nil
|
|
}
|
|
|
|
// ShouldSkipSettingsFlow flow returns true iff. `skip_settings` was requested
|
|
// in the URL query parameters.
|
|
func (f *Flow) ShouldSkipSettingsFlow() bool {
|
|
u, err := url.Parse(f.RequestURL)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
shouldSkip, _ := strconv.ParseBool(u.Query().Get("skip_settings"))
|
|
|
|
return shouldSkip
|
|
}
|
|
|
|
// SetShouldSkipSettingsFlow encodes shouldSkip in the flow's request URL. The
|
|
// setting can be retreived with `f.ShouldSkipSettingsFlow`
|
|
func (f *Flow) SetShouldSkipSettingsFlow(shouldSkip bool) error {
|
|
reqUrl, err := url.Parse(f.RequestURL)
|
|
if err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
q := reqUrl.Query()
|
|
q.Set("skip_settings", strconv.FormatBool(shouldSkip))
|
|
reqUrl.RawQuery = q.Encode()
|
|
f.RequestURL = reqUrl.String()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f *Flow) Valid(c clock.Clock) error {
|
|
if f.ExpiresAt.Before(c.Now().UTC()) {
|
|
return errors.WithStack(flow.NewFlowExpiredError(c, f.ExpiresAt))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *Flow) AppendTo(src *url.URL) *url.URL {
|
|
return urlx.CopyWithQuery(src, url.Values{"flow": {f.ID.String()}})
|
|
}
|
|
|
|
func (f *Flow) SetCSRFToken(token string) {
|
|
f.CSRFToken = token
|
|
f.UI.SetCSRF(token)
|
|
}
|
|
|
|
func (f Flow) MarshalJSON() ([]byte, error) {
|
|
type local Flow
|
|
f.SetReturnTo()
|
|
return json.Marshal(local(f))
|
|
}
|
|
|
|
func (f *Flow) SetReturnTo() {
|
|
// Return to is already set, do not overwrite it.
|
|
if len(f.ReturnTo) > 0 {
|
|
return
|
|
}
|
|
if u, err := url.Parse(f.RequestURL); err == nil {
|
|
f.ReturnTo = u.Query().Get("return_to")
|
|
}
|
|
}
|
|
|
|
func (f *Flow) AfterFind(*pop.Connection) error {
|
|
f.SetReturnTo()
|
|
return nil
|
|
}
|
|
|
|
func (f *Flow) AfterSave(*pop.Connection) error {
|
|
f.SetReturnTo()
|
|
return nil
|
|
}
|
|
|
|
func (f *Flow) ToLoggerField() map[string]any {
|
|
if f == nil {
|
|
return map[string]any{}
|
|
}
|
|
return map[string]any{
|
|
"id": f.ID.String(),
|
|
"return_to": f.ReturnTo,
|
|
"request_url": f.RequestURL,
|
|
"active": f.Active,
|
|
"type": f.Type,
|
|
"nid": f.NID,
|
|
"state": f.State,
|
|
}
|
|
}
|