snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-15 21:19:12 +05:00
commit c60cc7ca75
8092 changed files with 394160 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
{
"type": "api",
"request_url": "http:///",
"active": "link",
"ui": {
"method": "POST",
"nodes": [
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "email",
"type": "email",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070007,
"text": "Email",
"type": "info"
}
}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "method",
"type": "submit",
"value": "code",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070009,
"text": "Continue",
"type": "info"
}
}
}
],
"messages": [
{
"id": 4000001,
"text": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method.",
"type": "error",
"context": {
"reason": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method."
}
}
]
},
"state": "choose_method"
}

View File

@@ -0,0 +1,6 @@
{
"code": 400,
"message": "The request was malformed or contained invalid parameters",
"reason": "You attempted recovery using not-valid, which is not enabled or does not exist. An administrator needs to enable this recovery method.",
"status": "Bad Request"
}

View File

@@ -0,0 +1,72 @@
{
"type": "browser",
"request_url": "http:///",
"active": "link",
"ui": {
"method": "POST",
"nodes": [
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "email",
"type": "email",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070007,
"text": "Email",
"type": "info"
}
}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "method",
"type": "submit",
"value": "code",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070009,
"text": "Continue",
"type": "info"
}
}
}
],
"messages": [
{
"id": 4000001,
"text": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method.",
"type": "error",
"context": {
"reason": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method."
}
}
]
},
"state": "choose_method"
}

View File

@@ -0,0 +1,72 @@
{
"type": "api",
"request_url": "http:///",
"active": "link",
"ui": {
"method": "POST",
"nodes": [
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "email",
"type": "email",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070007,
"text": "Email",
"type": "info"
}
}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "method",
"type": "submit",
"value": "code",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070009,
"text": "Continue",
"type": "info"
}
}
}
],
"messages": [
{
"id": 4000001,
"text": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method.",
"type": "error",
"context": {
"reason": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method."
}
}
]
},
"state": "choose_method"
}

View File

@@ -0,0 +1,6 @@
{
"code": 400,
"message": "The request was malformed or contained invalid parameters",
"reason": "You attempted recovery using not-valid, which is not enabled or does not exist. An administrator needs to enable this recovery method.",
"status": "Bad Request"
}

View File

@@ -0,0 +1,72 @@
{
"type": "browser",
"request_url": "http:///",
"active": "link",
"ui": {
"method": "POST",
"nodes": [
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "email",
"type": "email",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070007,
"text": "Email",
"type": "info"
}
}
},
{
"type": "input",
"group": "code",
"attributes": {
"name": "method",
"type": "submit",
"value": "code",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070009,
"text": "Continue",
"type": "info"
}
}
}
],
"messages": [
{
"id": 4000001,
"text": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method.",
"type": "error",
"context": {
"reason": "You attempted recovery using code, which is not enabled or does not exist. An administrator needs to enable this recovery method."
}
}
]
},
"state": "choose_method"
}

View File

@@ -0,0 +1,187 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery
import (
"net/http"
"net/url"
"github.com/ory/kratos/x/nosurfx"
"github.com/gofrs/uuid"
"go.opentelemetry.io/otel/trace"
"github.com/ory/kratos/x/events"
"github.com/ory/x/clock"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx/semconv"
"github.com/ory/x/sqlxx"
"github.com/ory/kratos/ui/node"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/x/urlx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/selfservice/errorx"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/text"
"github.com/ory/kratos/x"
)
var ErrHookAbortFlow = errors.New("aborted recovery hook execution")
func ErrAlreadyLoggedIn() *herodot.DefaultError {
return herodot.ErrBadRequest().WithID(text.ErrIDAlreadyLoggedIn).WithReason("A valid session was detected and thus recovery is not possible.")
}
type (
errorHandlerDependencies interface {
clock.Provider
errorx.ManagementProvider
httpx.WriterProvider
logrusx.Provider
nosurfx.CSRFTokenGeneratorProvider
config.Provider
StrategyProvider
FlowPersistenceProvider
}
ErrorHandlerProvider interface {
RecoveryFlowErrorHandler() *ErrorHandler
}
ErrorHandler struct {
d errorHandlerDependencies
}
)
func NewErrorHandler(d errorHandlerDependencies) *ErrorHandler {
return &ErrorHandler{d: d}
}
func (s *ErrorHandler) WriteFlowError(
w http.ResponseWriter,
r *http.Request,
f *Flow,
group node.UiNodeGroup,
recoveryErr error,
) {
logger := s.d.Logger().
WithError(recoveryErr).
WithRequest(r).
WithField("recovery_flow", f.ToLoggerField())
logger.
Info("Encountered self-service recovery error.")
if f == nil {
trace.SpanFromContext(r.Context()).AddEvent(events.NewRecoveryFailed(r.Context(), uuid.Nil, "", "", recoveryErr))
s.forward(w, r, nil, recoveryErr)
return
}
trace.SpanFromContext(r.Context()).AddEvent(events.NewRecoveryFailed(r.Context(), f.ID, string(f.Type), f.Active.String(), recoveryErr))
if expiredError := new(flow.ExpiredError); errors.As(recoveryErr, &expiredError) {
strategies, _, err := s.d.RecoveryStrategies(r.Context()).ActiveStrategies(f.Active.String())
if err != nil {
strategies, _, err = s.d.GetActiveRecoveryStrategies(r.Context())
// Can't retry the recovery if no primary strategy has been set
if err != nil {
s.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
}
// create new flow because the old one is not valid
newFlow, err := FromOldFlow(s.d, s.d.Config().SelfServiceFlowRecoveryRequestLifespan(r.Context()), s.d.GenerateCSRFToken(r), r, strategies, *f)
if err != nil {
// failed to create a new session and redirect to it, handle that error as a new one
s.WriteFlowError(w, r, f, group, err)
return
}
newFlow.UI.Messages.Add(text.NewErrorValidationRecoveryFlowExpired(s.d.Clock(), expiredError.ExpiredAt))
if err := s.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), newFlow); err != nil {
s.forward(w, r, newFlow, err)
return
}
if s.d.Config().UseContinueWithTransitions(r.Context()) {
switch {
case newFlow.Type.IsAPI():
expiredError.FlowID = newFlow.ID
s.d.Writer().WriteError(w, r, expiredError.WithContinueWith(flow.NewContinueWithRecoveryUI(newFlow)))
case x.IsJSONRequest(r):
http.Redirect(w, r, urlx.CopyWithQuery(
urlx.AppendPaths(s.d.Config().SelfPublicURL(r.Context()), RouteGetFlow),
url.Values{"id": {newFlow.ID.String()}},
).String(), http.StatusSeeOther)
default:
http.Redirect(w, r, newFlow.AppendTo(s.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther)
}
} else {
trace.SpanFromContext(r.Context()).AddEvent(semconv.NewDeprecatedFeatureUsedEvent(r.Context(), "no_continue_with_transition_recovery_error_handler"))
// We need to use the new flow, as that flow will be a browser flow. Bug fix for:
//
// https://github.com/ory/kratos/issues/2049!!
if newFlow.Type == flow.TypeAPI || x.IsJSONRequest(r) {
http.Redirect(w, r, urlx.CopyWithQuery(urlx.AppendPaths(s.d.Config().SelfPublicURL(r.Context()),
RouteGetFlow), url.Values{"id": {newFlow.ID.String()}}).String(), http.StatusSeeOther)
} else {
http.Redirect(w, r, newFlow.AppendTo(s.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther)
}
}
return
}
f.UI.ResetMessages()
if err := f.UI.ParseError(group, recoveryErr); err != nil {
s.forward(w, r, f, err)
return
}
f.Active = sqlxx.NullString(group)
if err := s.d.RecoveryFlowPersister().UpdateRecoveryFlow(r.Context(), f); err != nil {
s.forward(w, r, f, err)
return
}
if f.Type == flow.TypeBrowser && !x.IsJSONRequest(r) {
http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther)
return
}
updatedFlow, innerErr := s.d.RecoveryFlowPersister().GetRecoveryFlow(r.Context(), f.ID)
if innerErr != nil {
s.forward(w, r, updatedFlow, innerErr)
return
}
s.d.Writer().WriteCode(w, r, x.RecoverStatusCode(recoveryErr, http.StatusBadRequest), updatedFlow)
}
func (s *ErrorHandler) forward(w http.ResponseWriter, r *http.Request, rr *Flow, err error) {
if rr == nil {
if x.IsJSONRequest(r) {
s.d.Writer().WriteError(w, r, err)
return
}
s.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
if rr.Type == flow.TypeAPI || x.IsJSONRequest(r) {
s.d.Writer().WriteErrorCode(w, r, x.RecoverStatusCode(err, http.StatusBadRequest), err)
} else {
s.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
}
}

View File

@@ -0,0 +1,571 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/x/clock"
"github.com/ory/x/configx"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/assertx"
"github.com/ory/x/ioutilx"
"github.com/ory/x/jsonx"
"github.com/ory/x/snapshotx"
"github.com/ory/x/urlx"
)
func TestHandleError(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(map[string]any{
config.ViperKeySelfServiceRecoveryEnabled: true,
config.ViperKeySelfServiceRecoveryUse: "code",
}),
)
public, _ := testhelpers.NewKratosServer(t, reg)
router := http.NewServeMux()
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
testhelpers.NewRecoveryUIFlowEchoServer(t, reg)
testhelpers.NewErrorTestServer(t, reg)
h := reg.RecoveryFlowErrorHandler()
sdk := testhelpers.NewSDKClient(public)
var recoveryFlow *recovery.Flow
var flowError error
var methodName node.UiNodeGroup
router.HandleFunc("GET /error", func(w http.ResponseWriter, r *http.Request) {
h.WriteFlowError(w, r, recoveryFlow, methodName, flowError)
})
reset := func() {
recoveryFlow = nil
flowError = nil
methodName = ""
}
newFlow := func(t *testing.T, ttl time.Duration, ft flow.Type) *recovery.Flow {
req := &http.Request{URL: urlx.ParseOrPanic("/")}
s, _, err := reg.GetActiveRecoveryStrategies(context.Background())
require.NoError(t, err)
f, err := recovery.NewFlow(reg, ttl, nosurfx.FakeCSRFToken, req, s, ft)
require.NoError(t, err)
require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(context.Background(), f))
f, err = reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), f.ID)
require.NoError(t, err)
return f
}
expectErrorUI := func(t *testing.T) (map[string]interface{}, *http.Response) {
res, err := ts.Client().Get(ts.URL + "/error")
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=")
sse, _, err := sdk.FrontendAPI.GetFlowError(context.Background()).Id(res.Request.URL.Query().Get("id")).Execute()
require.NoError(t, err)
return sse.Error, nil
}
anHourAgo := time.Now().Add(-time.Hour)
t.Run("case=error with nil flow defaults to error ui redirect", func(t *testing.T) {
t.Cleanup(reset)
flowError = herodot.ErrInternalServerError().WithReason("system error")
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
sse, _ := expectErrorUI(t)
assertx.EqualAsJSON(t, flowError, sse)
})
t.Run("case=error with nil flow detects application/json", func(t *testing.T) {
t.Cleanup(reset)
flowError = herodot.ErrInternalServerError().WithReason("system error")
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Contains(t, res.Header.Get("Content-Type"), "application/json")
assert.NotContains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=")
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "system error")
})
for _, tc := range []struct {
n string
t flow.Type
}{
{"api", flow.TypeAPI},
{"spa", flow.TypeBrowser},
} {
t.Run("flow="+tc.n, func(t *testing.T) {
t.Run("case=expired error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Contains(t, res.Request.URL.String(), public.URL+recovery.RouteGetFlow)
require.Equal(t, http.StatusOK, res.StatusCode, "%+v", res.Request)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), string(body))
assert.NotEqual(t, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String())
})
t.Run("case=validation error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = schema.NewInvalidCredentialsError()
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, int(text.ErrorValidationInvalidCredentials), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), "%s", body)
assert.Equal(t, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String())
})
t.Run("case=generic error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = herodot.ErrInternalServerError().WithReason("system error")
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Equal(t, http.StatusInternalServerError, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.JSONEq(t, x.MustEncodeJSON(t, flowError), gjson.GetBytes(body, "error").Raw)
})
t.Run("case=fails if active strategy is disabled", func(t *testing.T) {
c, reg := pkg.NewVeryFastRegistryWithoutDB(t)
require.NoError(t, c.Set(context.Background(), "selfservice.methods.code.enabled", false))
require.NoError(t, c.Set(context.Background(), config.ViperKeySelfServiceRecoveryUse, "code"))
_, _, err := reg.GetActiveRecoveryStrategies(context.Background())
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = err
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
snapshotx.SnapshotTJSON(t, body, snapshotx.ExceptPaths("id", "expires_at", "issued_at", "ui.action", "ui.nodes.0.attributes.value"))
})
})
}
t.Run("flow=browser", func(t *testing.T) {
expectRecoveryUI := func(t *testing.T) (*recovery.Flow, *http.Response) {
res, err := ts.Client().Get(ts.URL + "/error")
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowRecoveryUI(ctx).String()+"?flow=")
rf, err := reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), uuid.FromStringOrNil(res.Request.URL.Query().Get("flow")))
require.NoError(t, err)
return rf, res
}
t.Run("case=expired error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser}
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
methodName = node.LinkGroup
lf, _ := expectRecoveryUI(t)
require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf))
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID))
})
t.Run("case=validation error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, flow.TypeBrowser)
flowError = schema.NewInvalidCredentialsError()
methodName = node.LinkGroup
lf, _ := expectRecoveryUI(t)
require.NotEmpty(t, lf.UI, x.MustEncodeJSON(t, lf))
require.Len(t, lf.UI.Messages, 1, x.MustEncodeJSON(t, lf))
assert.Equal(t, int(text.ErrorValidationInvalidCredentials), int(lf.UI.Messages[0].ID), x.MustEncodeJSON(t, lf))
})
t.Run("case=generic error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, flow.TypeBrowser)
flowError = herodot.ErrInternalServerError().WithReason("system error")
methodName = node.LinkGroup
sse, _ := expectErrorUI(t)
assertx.EqualAsJSON(t, flowError, sse)
})
t.Run("case=new flow uses strategy of old flow", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "code"}
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
lf, _ := expectRecoveryUI(t)
require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf))
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID))
assert.Equal(t, recoveryFlow.Active.String(), lf.Active.String())
})
t.Run("case=new flow uses current strategy if strategy of old flow does not exist", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "not-valid"}
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
lf, _ := expectRecoveryUI(t)
require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf))
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID))
assert.Equal(t, "code", lf.Active.String())
})
t.Run("case=fails to retry flow if recovery strategy id is not valid", func(t *testing.T) {
t.Cleanup(func() {
reset()
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "code")
})
recoveryFlow = newFlow(t, 0, flow.TypeBrowser)
recoveryFlow.Active = "not-valid"
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "not-valid")
sse, _ := expectErrorUI(t)
snapshotx.SnapshotT(t, sse)
})
})
}
func TestHandleError_WithContinueWith(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(map[string]any{
config.ViperKeyUseContinueWithTransitions: true,
config.ViperKeySelfServiceRecoveryEnabled: true,
config.ViperKeySelfServiceRecoveryUse: "code",
}),
)
public, _ := testhelpers.NewKratosServer(t, reg)
router := http.NewServeMux()
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
testhelpers.NewRecoveryUIFlowEchoServer(t, reg)
testhelpers.NewErrorTestServer(t, reg)
h := reg.RecoveryFlowErrorHandler()
sdk := testhelpers.NewSDKClient(public)
var recoveryFlow *recovery.Flow
var flowError error
var methodName node.UiNodeGroup
router.HandleFunc("GET /error", func(w http.ResponseWriter, r *http.Request) {
h.WriteFlowError(w, r, recoveryFlow, methodName, flowError)
})
reset := func() {
recoveryFlow = nil
flowError = nil
methodName = ""
}
newFlow := func(t *testing.T, ttl time.Duration, ft flow.Type) *recovery.Flow {
req := &http.Request{URL: urlx.ParseOrPanic("/")}
s, _, err := reg.GetActiveRecoveryStrategies(context.Background())
require.NoError(t, err)
f, err := recovery.NewFlow(reg, ttl, nosurfx.FakeCSRFToken, req, s, ft)
require.NoError(t, err)
require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(context.Background(), f))
f, err = reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), f.ID)
require.NoError(t, err)
return f
}
expectErrorUI := func(t *testing.T) (map[string]interface{}, *http.Response) {
res, err := ts.Client().Get(ts.URL + "/error")
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=")
sse, _, err := sdk.FrontendAPI.GetFlowError(context.Background()).Id(res.Request.URL.Query().Get("id")).Execute()
require.NoError(t, err)
return sse.Error, nil
}
anHourAgo := time.Now().Add(-time.Hour)
t.Run("case=error with nil flow defaults to error ui redirect", func(t *testing.T) {
t.Cleanup(reset)
flowError = herodot.ErrInternalServerError().WithReason("system error")
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
sse, _ := expectErrorUI(t)
assertx.EqualAsJSON(t, flowError, sse)
})
t.Run("case=error with nil flow detects application/json", func(t *testing.T) {
t.Cleanup(reset)
flowError = herodot.ErrInternalServerError().WithReason("system error")
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Contains(t, res.Header.Get("Content-Type"), "application/json")
assert.NotContains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=")
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Contains(t, string(body), "system error")
})
for _, tc := range []struct {
n string
t flow.Type
}{
{"api", flow.TypeAPI},
{"spa", flow.TypeBrowser},
} {
t.Run("flow="+tc.n, func(t *testing.T) {
t.Run("case=expired error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
body := ioutilx.MustReadAll(res.Body)
switch tc.t {
case flow.TypeAPI:
require.Equal(t, http.StatusGone, res.StatusCode, "%s", body)
require.Len(t, gjson.GetBytes(body, "error.details.continue_with").Array(), 1, "%s", body)
require.Equal(t, "show_recovery_ui", gjson.GetBytes(body, "error.details.continue_with.0.action").String(), "%s", body)
id := gjson.GetBytes(body, "error.details.continue_with.0.flow.id").String()
res, err = ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, public.URL+recovery.RouteGetFlow+"?id="+id))
require.NoError(t, err)
body = ioutilx.MustReadAll(res.Body)
case flow.TypeBrowser:
require.Contains(t, res.Request.URL.String(), public.URL+recovery.RouteGetFlow, "%s", body)
require.Equal(t, http.StatusOK, res.StatusCode, "%+v", res.Request)
}
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), string(body))
assert.NotEqual(t, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String())
})
t.Run("case=validation error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = schema.NewInvalidCredentialsError()
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, int(text.ErrorValidationInvalidCredentials), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), "%s", body)
assert.Equal(t, recoveryFlow.ID.String(), gjson.GetBytes(body, "id").String())
})
t.Run("case=generic error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = herodot.ErrInternalServerError().WithReason("system error")
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Equal(t, http.StatusInternalServerError, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.JSONEq(t, x.MustEncodeJSON(t, flowError), gjson.GetBytes(body, "error").Raw)
})
t.Run("case=fails if active strategy is disabled", func(t *testing.T) {
c, reg := pkg.NewVeryFastRegistryWithoutDB(t)
require.NoError(t, c.Set(context.Background(), "selfservice.methods.code.enabled", false))
require.NoError(t, c.Set(context.Background(), config.ViperKeySelfServiceRecoveryUse, "code"))
_, _, err := reg.GetActiveRecoveryStrategies(context.Background())
recoveryFlow = newFlow(t, time.Minute, tc.t)
flowError = err
methodName = node.UiNodeGroup(recovery.RecoveryStrategyLink)
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
snapshotx.SnapshotTJSON(t, body, snapshotx.ExceptPaths("id", "expires_at", "issued_at", "ui.action", "ui.nodes.0.attributes.value"))
})
})
}
t.Run("flow=browser", func(t *testing.T) {
expectRecoveryUI := func(t *testing.T) (*recovery.Flow, *http.Response) {
res, err := ts.Client().Get(ts.URL + "/error")
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowRecoveryUI(ctx).String()+"?flow=")
rf, err := reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), uuid.FromStringOrNil(res.Request.URL.Query().Get("flow")))
require.NoError(t, err)
return rf, res
}
t.Run("case=expired error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser}
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
methodName = node.LinkGroup
lf, _ := expectRecoveryUI(t)
require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf))
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID))
})
t.Run("case=validation error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, flow.TypeBrowser)
flowError = schema.NewInvalidCredentialsError()
methodName = node.LinkGroup
lf, _ := expectRecoveryUI(t)
require.NotEmpty(t, lf.UI, x.MustEncodeJSON(t, lf))
require.Len(t, lf.UI.Messages, 1, x.MustEncodeJSON(t, lf))
assert.Equal(t, int(text.ErrorValidationInvalidCredentials), int(lf.UI.Messages[0].ID), x.MustEncodeJSON(t, lf))
})
t.Run("case=generic error", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = newFlow(t, time.Minute, flow.TypeBrowser)
flowError = herodot.ErrInternalServerError().WithReason("system error")
methodName = node.LinkGroup
sse, _ := expectErrorUI(t)
assertx.EqualAsJSON(t, flowError, sse)
})
t.Run("case=new flow uses strategy of old flow", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "code"}
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
lf, _ := expectRecoveryUI(t)
require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf))
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID))
assert.Equal(t, recoveryFlow.Active.String(), lf.Active.String())
})
t.Run("case=new flow uses current strategy if strategy of old flow does not exist", func(t *testing.T) {
t.Cleanup(reset)
recoveryFlow = &recovery.Flow{Type: flow.TypeBrowser, Active: "not-valid"}
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
lf, _ := expectRecoveryUI(t)
require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf))
assert.Equal(t, int(text.ErrorValidationRecoveryFlowExpired), int(lf.UI.Messages[0].ID))
assert.Equal(t, "code", lf.Active.String())
})
t.Run("case=fails to retry flow if recovery strategy id is not valid", func(t *testing.T) {
t.Cleanup(func() {
reset()
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "code")
})
recoveryFlow = newFlow(t, 0, flow.TypeBrowser)
recoveryFlow.Active = "not-valid"
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "not-valid")
sse, _ := expectErrorUI(t)
snapshotx.SnapshotT(t, sse)
})
})
}

View File

@@ -0,0 +1,334 @@
// 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,
}
}

View File

@@ -0,0 +1,212 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery_test
import (
"context"
"fmt"
"net/http"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/selfservice/strategy/code"
"github.com/ory/kratos/selfservice/strategy/link"
"github.com/ory/kratos/x"
"github.com/ory/x/clock"
"github.com/ory/x/jsonx"
"github.com/ory/x/urlx"
)
func TestFlow(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewVeryFastRegistryWithoutDB(t)
must := func(r *recovery.Flow, err error) *recovery.Flow {
require.NoError(t, err)
return r
}
u := &http.Request{URL: urlx.ParseOrPanic("http://foo/bar/baz"), Host: "foo"}
for k, tc := range []struct {
r *recovery.Flow
expectErr bool
}{
{r: must(recovery.NewFlow(reg, time.Hour, "", u, nil, flow.TypeBrowser))},
{r: must(recovery.NewFlow(reg, -time.Hour, "", u, nil, flow.TypeBrowser)), expectErr: true},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
err := tc.r.Valid(clock.New())
if tc.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
assert.EqualValues(t, flow.StateChooseMethod,
must(recovery.NewFlow(reg, time.Hour, "", u, nil, flow.TypeBrowser)).State)
t.Run("type=return_to", func(t *testing.T) {
_, err := recovery.NewFlow(reg, 0, "csrf", &http.Request{URL: &url.URL{Path: "/", RawQuery: "return_to=https://not-allowed/foobar"}, Host: "ory.sh"}, nil, flow.TypeBrowser)
require.Error(t, err)
_, err = recovery.NewFlow(reg, 0, "csrf", &http.Request{URL: &url.URL{Path: "/", RawQuery: "return_to=" + urlx.AppendPaths(conf.SelfPublicURL(ctx), "/self-service/login/browser").String()}, Host: "ory.sh"}, nil, flow.TypeBrowser)
require.NoError(t, err)
})
}
func TestGetType(t *testing.T) {
for _, ft := range []flow.Type{
flow.TypeAPI,
flow.TypeBrowser,
} {
t.Run(fmt.Sprintf("case=%s", ft), func(t *testing.T) {
r := &recovery.Flow{Type: ft}
assert.Equal(t, ft, r.GetType())
})
}
}
func TestGetRequestURL(t *testing.T) {
expectedURL := "http://foo/bar/baz"
f := &recovery.Flow{RequestURL: expectedURL}
assert.Equal(t, expectedURL, f.GetRequestURL())
}
func TestNewFlow_capturesCourierBaseURL(t *testing.T) {
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
t.Run("nothing in context", func(t *testing.T) {
r := &http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}
f, err := recovery.NewFlow(reg, time.Hour, "", r, nil, flow.TypeBrowser)
require.NoError(t, err)
assert.Equal(t, "", f.GetCourierBaseURL())
})
t.Run("BaseURL in request context is copied onto flow", func(t *testing.T) {
base := urlx.ParseOrPanic("https://customer.example.com/")
r := (&http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}).
WithContext(x.WithBaseURL(context.Background(), base))
f, err := recovery.NewFlow(reg, time.Hour, "", r, nil, flow.TypeBrowser)
require.NoError(t, err)
assert.Equal(t, base.String(), f.GetCourierBaseURL())
})
t.Run("http scheme is preserved (not coerced to https)", func(t *testing.T) {
base := urlx.ParseOrPanic("http://localhost:4000")
r := (&http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}).
WithContext(x.WithBaseURL(context.Background(), base))
f, err := recovery.NewFlow(reg, time.Hour, "", r, nil, flow.TypeBrowser)
require.NoError(t, err)
assert.Equal(t, "http://localhost:4000", f.GetCourierBaseURL())
})
}
func TestFlowEncodeJSON(t *testing.T) {
assert.EqualValues(t, "", gjson.Get(jsonx.TestMarshalJSONString(t, &recovery.Flow{RequestURL: "https://foo.bar?foo=bar"}), "return_to").String())
assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, &recovery.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String())
assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, recovery.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String())
}
func TestFromOldFlow(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewVeryFastRegistryWithoutDB(t)
r := http.Request{URL: &url.URL{Path: "/", RawQuery: "return_to=" + urlx.AppendPaths(conf.SelfPublicURL(ctx), "/self-service/login/browser").String()}, Host: "ory.sh"}
t.Run("strategy=code", func(t *testing.T) {
for _, ft := range []flow.Type{
flow.TypeAPI,
flow.TypeBrowser,
} {
t.Run(fmt.Sprintf("case=original flow is %s", ft), func(t *testing.T) {
f, err := recovery.NewFlow(reg, 0, "csrf", &r, recovery.Strategies{code.NewStrategy(reg)}, ft)
require.NoError(t, err)
nF, err := recovery.FromOldFlow(reg, time.Duration(time.Hour), f.CSRFToken, &r, nil, *f)
require.NoError(t, err)
require.Equal(t, ft, nF.Type)
})
}
})
t.Run("strategy=link", func(t *testing.T) {
for _, ft := range []flow.Type{
flow.TypeAPI,
flow.TypeBrowser,
} {
t.Run(fmt.Sprintf("case=original flow is %s", ft), func(t *testing.T) {
f, err := recovery.NewFlow(reg, 0, "csrf", &r, recovery.Strategies{link.NewStrategy(reg)}, ft)
require.NoError(t, err)
nF, err := recovery.FromOldFlow(reg, time.Duration(time.Hour), f.CSRFToken, &r, nil, *f)
require.NoError(t, err)
require.Equal(t, flow.TypeBrowser, nF.Type)
})
}
})
}
func TestFromOldFlow_capturesCourierBaseURL(t *testing.T) {
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
t.Run("uses the new request's context, not the old flow's value", func(t *testing.T) {
// Old flow was created at customer.example.com.
oldR := (&http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}).
WithContext(x.WithBaseURL(context.Background(), urlx.ParseOrPanic("https://customer.example.com/")))
oldF, err := recovery.NewFlow(reg, time.Hour, "csrf", oldR, nil, flow.TypeBrowser)
require.NoError(t, err)
require.Equal(t, "https://customer.example.com/", oldF.GetCourierBaseURL())
// New request comes through a different proxy / CNAME — we should
// reflect that, not silently inherit the old flow's captured URL.
newR := (&http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}).
WithContext(x.WithBaseURL(context.Background(), urlx.ParseOrPanic("http://localhost:4000")))
newF, err := recovery.FromOldFlow(reg, time.Hour, oldF.CSRFToken, newR, nil, *oldF)
require.NoError(t, err)
assert.Equal(t, "http://localhost:4000", newF.GetCourierBaseURL())
})
t.Run("empty when neither old nor new have a base URL", func(t *testing.T) {
r := &http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}
oldF, err := recovery.NewFlow(reg, time.Hour, "csrf", r, nil, flow.TypeBrowser)
require.NoError(t, err)
newF, err := recovery.FromOldFlow(reg, time.Hour, oldF.CSRFToken, r, nil, *oldF)
require.NoError(t, err)
assert.Equal(t, "", newF.GetCourierBaseURL())
})
}
// TestCourierBaseURLStoredInInternalContext locks down the storage
// location: the captured base URL must land in InternalContext under
// flow.InternalContextKeyCourierBaseURL, not in a dedicated column. If a
// future refactor moves it elsewhere, this fails — separately from the
// getter contract.
func TestCourierBaseURLStoredInInternalContext(t *testing.T) {
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
r := (&http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}).
WithContext(x.WithBaseURL(context.Background(), urlx.ParseOrPanic("https://customer.example.com/")))
f, err := recovery.NewFlow(reg, time.Hour, "csrf", r, nil, flow.TypeBrowser)
require.NoError(t, err)
got := gjson.GetBytes(f.InternalContext, flow.InternalContextKeyCourierBaseURL).String()
assert.Equal(t, "https://customer.example.com/", got)
}
func TestFlowDontOverrideReturnTo(t *testing.T) {
f := &recovery.Flow{ReturnTo: "/foo"}
f.SetReturnTo()
assert.Equal(t, "/foo", f.ReturnTo)
f = &recovery.Flow{RequestURL: "https://foo.bar?return_to=/bar"}
f.SetReturnTo()
assert.Equal(t, "/bar", f.ReturnTo)
}

View File

@@ -0,0 +1,480 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery
import (
"net/http"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/errorx"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/session"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/x/redir"
"github.com/ory/nosurf"
"github.com/ory/x/clock"
"github.com/ory/x/httprouterx"
"github.com/ory/x/httpx"
"github.com/ory/x/sqlcon"
"github.com/ory/x/urlx"
)
const (
RouteInitBrowserFlow = "/self-service/recovery/browser"
RouteInitAPIFlow = "/self-service/recovery/api"
RouteGetFlow = "/self-service/recovery/flows"
RouteSubmitFlow = "/self-service/recovery"
)
type (
HandlerProvider interface {
RecoveryHandler() *Handler
}
handlerDependencies interface {
clock.Provider
errorx.ManagementProvider
identity.ManagementProvider
identity.PrivilegedPoolProvider
session.HandlerProvider
StrategyProvider
FlowPersistenceProvider
nosurfx.CSRFTokenGeneratorProvider
httpx.WriterProvider
nosurfx.CSRFProvider
config.Provider
ErrorHandlerProvider
HookExecutorProvider
}
Handler struct {
d handlerDependencies
}
)
func NewHandler(d handlerDependencies) *Handler { return &Handler{d: d} }
func (h *Handler) RegisterPublicRoutes(public *httprouterx.RouterPublic) {
h.d.CSRFHandler().IgnorePath(RouteInitAPIFlow)
h.d.CSRFHandler().IgnorePath(RouteSubmitFlow)
redirect := session.RedirectOnAuthenticated(h.d)
public.GET(RouteInitBrowserFlow, h.d.SessionHandler().IsNotAuthenticated(h.createBrowserRecoveryFlow, func(w http.ResponseWriter, r *http.Request) {
if x.IsJSONRequest(r) {
h.d.Writer().WriteError(w, r, errors.WithStack(ErrAlreadyLoggedIn()))
} else {
redirect(w, r)
}
}))
public.GET(RouteInitAPIFlow, h.d.SessionHandler().IsNotAuthenticated(h.createNativeRecoveryFlow,
session.RespondWithJSONErrorOnAuthenticated(h.d.Writer(), ErrAlreadyLoggedIn())))
public.GET(RouteGetFlow, h.getRecoveryFlow)
public.GET(RouteSubmitFlow, h.updateRecoveryFlow)
public.POST(RouteSubmitFlow, h.updateRecoveryFlow)
}
func (h *Handler) RegisterAdminRoutes(admin *httprouterx.RouterAdmin) {
admin.GET(RouteInitBrowserFlow, redir.RedirectToPublicRoute(h.d))
admin.GET(RouteInitAPIFlow, redir.RedirectToPublicRoute(h.d))
admin.GET(RouteGetFlow, redir.RedirectToPublicRoute(h.d))
admin.GET(RouteSubmitFlow, redir.RedirectToPublicRoute(h.d))
admin.POST(RouteSubmitFlow, redir.RedirectToPublicRoute(h.d))
}
// swagger:route GET /self-service/recovery/api frontend createNativeRecoveryFlow
//
// # Create Recovery Flow for Native Apps
//
// This endpoint initiates a recovery flow for API clients such as mobile devices, smart TVs, and so on.
//
// If a valid provided session cookie or session token is provided, a 400 Bad Request error.
//
// On an existing recovery flow, use the `getRecoveryFlow` API endpoint.
//
// You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server
// Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make
// you vulnerable to a variety of CSRF attacks.
//
// This endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...).
//
// More information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).
//
// Schemes: http, https
//
// Responses:
// 200: recoveryFlow
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-medium
func (h *Handler) createNativeRecoveryFlow(w http.ResponseWriter, r *http.Request) {
if !h.d.Config().SelfServiceFlowRecoveryEnabled(r.Context()) {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Recovery is not allowed because it was disabled.")))
return
}
activeRecoveryStrategies, _, err := h.d.GetActiveRecoveryStrategies(r.Context())
if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
f, err := NewFlow(h.d, h.d.Config().SelfServiceFlowRecoveryRequestLifespan(r.Context()), h.d.GenerateCSRFToken(r), r, activeRecoveryStrategies, flow.TypeAPI)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if err := h.d.RecoveryExecutor().PreRecoveryHook(w, r, f); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if err := h.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), f); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
h.d.Writer().Write(w, r, f)
}
// Create Browser Recovery Flow Parameters
//
// swagger:parameters createBrowserRecoveryFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type createBrowserRecoveryFlow struct {
// The URL to return the browser to after the flow was completed.
//
// in: query
ReturnTo string `json:"return_to"`
// Skip redirection to the settings UI after the recovery flow was
// completed. Instead, the user will be redirected to the URL specified in
// `return_to` query parameter or the default return URL if `return_to` is
// not set.
//
// in: query
SkipSettings string `json:"skip_settings"`
}
// swagger:route GET /self-service/recovery/browser frontend createBrowserRecoveryFlow
//
// # Create Recovery Flow for Browsers
//
// This endpoint initializes a browser-based account recovery flow. Once initialized, the browser will be redirected to
// `selfservice.flows.recovery.ui_url` with the flow ID set as the query parameter `?flow=`. If a valid user session
// exists, the browser is returned to the configured return URL.
//
// If this endpoint is called via an AJAX request, the response contains the recovery flow without any redirects
// or a 400 bad request error if the user is already authenticated.
//
// This endpoint is NOT INTENDED for clients that do not have a browser (Chrome, Firefox, ...) as cookies are needed.
//
// More information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).
//
// Schemes: http, https
//
// Responses:
// 200: recoveryFlow
// 303: emptyResponse
// 400: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-medium
func (h *Handler) createBrowserRecoveryFlow(w http.ResponseWriter, r *http.Request) {
if !h.d.Config().SelfServiceFlowRecoveryEnabled(r.Context()) {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Recovery is not allowed because it was disabled.")))
return
}
activeRecoveryStrategies, _, err := h.d.GetActiveRecoveryStrategies(r.Context())
if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
f, err := NewFlow(h.d, h.d.Config().SelfServiceFlowRecoveryRequestLifespan(r.Context()), h.d.GenerateCSRFToken(r), r, activeRecoveryStrategies, flow.TypeBrowser)
if err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
if err := h.d.RecoveryExecutor().PreRecoveryHook(w, r, f); err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
if err := h.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), f); err != nil {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
return
}
redirTo := f.AppendTo(h.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String()
x.SendFlowCompletedAsRedirectOrJSON(w, r, h.d.Writer(), f, redirTo)
}
// Get Recovery Flow Parameters
//
// swagger:parameters getRecoveryFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type getRecoveryFlow struct {
// The Flow ID
//
// The value for this parameter comes from `request` URL Query parameter sent to your
// application (e.g. `/recovery?flow=abcde`).
//
// required: true
// in: query
FlowID string `json:"id"`
// HTTP Cookies
//
// When using the SDK in a browser app, on the server side you must include the HTTP Cookie Header
// sent by the client to your server here. This ensures that CSRF and session cookies are respected.
//
// in: header
// name: Cookie
Cookies string `json:"Cookie"`
}
// swagger:route GET /self-service/recovery/flows frontend getRecoveryFlow
//
// # Get Recovery Flow
//
// This endpoint returns a recovery flow's context with, for example, error details and other information.
//
// Browser flows expect the anti-CSRF cookie to be included in the request's HTTP Cookie Header.
// For AJAX requests you must ensure that cookies are included in the request or requests will fail.
//
// If you use the browser-flow for server-side apps, the services need to run on a common top-level-domain
// and you need to forward the incoming HTTP Cookie header to this endpoint:
//
// ```js
// // pseudo-code example
// router.get('/recovery', async function (req, res) {
// const flow = await client.getRecoveryFlow(req.header('Cookie'), req.query['flow'])
//
// res.render('recovery', flow)
// })
// ```
//
// More information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: recoveryFlow
// 404: errorGeneric
// 410: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-high
func (h *Handler) getRecoveryFlow(w http.ResponseWriter, r *http.Request) {
if !h.d.Config().SelfServiceFlowRecoveryEnabled(r.Context()) {
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Recovery is not allowed because it was disabled.")))
return
}
rid := x.ParseUUID(r.URL.Query().Get("id"))
f, err := h.d.RecoveryFlowPersister().GetRecoveryFlow(r.Context(), rid)
if err != nil {
h.d.Writer().WriteError(w, r, err)
return
}
// Browser flows must include the CSRF token
//
// Resolves: https://github.com/ory/kratos/issues/1282
if f.Type.IsBrowser() && !f.DangerousSkipCSRFCheck && !nosurf.VerifyToken(h.d.GenerateCSRFToken(r), f.CSRFToken) {
h.d.Writer().WriteError(w, r, nosurfx.CSRFErrorReason(r, h.d))
return
}
if f.ExpiresAt.Before(h.d.Clock().Now().UTC()) {
if f.Type == flow.TypeBrowser {
redirectURL := flow.GetFlowExpiredRedirectURL(r.Context(), h.d.Config(), RouteInitBrowserFlow, f.ReturnTo)
h.d.Writer().WriteError(w, r, errors.WithStack(nosurfx.ErrGone().
WithReason("The recovery flow has expired. Redirect the user to the recovery flow init endpoint to initialize a new recovery flow.").
WithDetail("redirect_to", redirectURL.String()).
WithDetail("return_to", f.ReturnTo)))
return
}
h.d.Writer().WriteError(w, r, flow.NewFlowExpiredError(h.d.Clock(), f.ExpiresAt).
WithDetail("api", urlx.AppendPaths(h.d.Config().SelfPublicURL(r.Context()), RouteInitAPIFlow).String()))
return
}
h.d.Writer().Write(w, r, f)
}
// Update Recovery Flow Parameters
//
// swagger:parameters updateRecoveryFlow
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type updateRecoveryFlow struct {
// The Recovery Flow ID
//
// The value for this parameter comes from `flow` URL Query parameter sent to your
// application (e.g. `/recovery?flow=abcde`).
//
// required: true
// in: query
Flow string `json:"flow"`
// Recovery Token
//
// The recovery token which completes the recovery request. If the token
// is invalid (e.g. expired) an error will be shown to the end-user.
//
// This parameter is usually set in a link and not used by any direct API call.
//
// in: query
Token string `json:"token" form:"token"`
// in: body
// required: true
Body updateRecoveryFlowBody
// HTTP Cookies
//
// When using the SDK in a browser app, on the server side you must include the HTTP Cookie Header
// sent by the client to your server here. This ensures that CSRF and session cookies are respected.
//
// in: header
// name: Cookie
Cookies string `json:"Cookie"`
}
// Update Recovery Flow Request Body
//
// swagger:model updateRecoveryFlowBody
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type updateRecoveryFlowBody struct{}
// swagger:route POST /self-service/recovery frontend updateRecoveryFlow
//
// # Update Recovery Flow
//
// Use this endpoint to update a recovery flow. This endpoint
// behaves differently for API and browser flows and has several states:
//
// - `choose_method` expects `flow` (in the URL query) and `email` (in the body) to be sent
// and works with API- and Browser-initiated flows.
// - For API clients and Browser clients with HTTP Header `Accept: application/json` it either returns a HTTP 200 OK when the form is valid and HTTP 400 OK when the form is invalid.
// and a HTTP 303 See Other redirect with a fresh recovery flow if the flow was otherwise invalid (e.g. expired).
// - For Browser clients without HTTP Header `Accept` or with `Accept: text/*` it returns a HTTP 303 See Other redirect to the Recovery UI URL with the Recovery Flow ID appended.
// - `sent_email` is the success state after `choose_method` for the `link` method and allows the user to request another recovery email. It
// works for both API and Browser-initiated flows and returns the same responses as the flow in `choose_method` state.
// - `passed_challenge` expects a `token` to be sent in the URL query and given the nature of the flow ("sending a recovery link")
// does not have any API capabilities. The server responds with a HTTP 303 See Other redirect either to the Settings UI URL
// (if the link was valid) and instructs the user to update their password, or a redirect to the Recover UI URL with
// a new Recovery Flow ID which contains an error message that the recovery link was invalid.
//
// More information can be found at [Ory Kratos Account Recovery Documentation](../self-service/flows/account-recovery).
//
// Consumes:
// - application/json
// - application/x-www-form-urlencoded
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: recoveryFlow
// 303: emptyResponse
// 400: recoveryFlow
// 410: errorGeneric
// 422: errorBrowserLocationChangeRequired
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-public-low
func (h *Handler) updateRecoveryFlow(w http.ResponseWriter, r *http.Request) {
rid, err := flow.GetFlowID(r)
if err != nil {
h.d.RecoveryFlowErrorHandler().WriteFlowError(w, r, nil, node.DefaultGroup, err)
return
}
f, err := h.d.RecoveryFlowPersister().GetRecoveryFlow(r.Context(), rid)
if errors.Is(err, sqlcon.ErrNoRows()) {
h.d.RecoveryFlowErrorHandler().WriteFlowError(w, r, nil, node.DefaultGroup, errors.WithStack(herodot.ErrNotFound().WithReasonf("The recovery request could not be found. Please restart the flow.")))
return
} else if err != nil {
h.d.RecoveryFlowErrorHandler().WriteFlowError(w, r, nil, node.DefaultGroup, err)
return
}
if err := f.Valid(h.d.Clock()); err != nil {
h.d.RecoveryFlowErrorHandler().WriteFlowError(w, r, f, node.DefaultGroup, err)
return
}
var g node.UiNodeGroup
var found bool
for _, ss := range h.d.AllRecoveryStrategies() {
err := ss.Recover(w, r, f)
if errors.Is(err, flow.ErrStrategyNotResponsible) {
continue
} else if errors.Is(err, flow.ErrCompletedByStrategy) {
return
} else if err != nil {
h.d.RecoveryFlowErrorHandler().WriteFlowError(w, r, f, ss.NodeGroup(), err)
return
}
found = true
g = ss.NodeGroup()
break
}
if !found {
h.d.RecoveryFlowErrorHandler().WriteFlowError(w, r, f, node.DefaultGroup, errors.WithStack(schema.NewNoRecoveryStrategyResponsible()))
return
}
// WARNING - just because no error was returned does not mean that the challenge was accepted. Instead, the
// success state is available as:
//
// if f.State == flow.StatePassedChallenge
if f.Type == flow.TypeBrowser && !x.IsJSONRequest(r) {
http.Redirect(w, r, f.AppendTo(h.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther)
return
}
updatedFlow, err := h.d.RecoveryFlowPersister().GetRecoveryFlow(r.Context(), f.ID)
if err != nil {
h.d.RecoveryFlowErrorHandler().WriteFlowError(w, r, f, g, err)
return
}
updatedFlow.TransientPayload = f.TransientPayload
h.d.Writer().Write(w, r, updatedFlow)
}

View File

@@ -0,0 +1,301 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/corpx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/text"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/assertx"
"github.com/ory/x/configx"
"github.com/ory/x/httprouterx"
)
func init() {
corpx.RegisterFakes()
}
func TestHandlerRedirectOnAuthenticated(t *testing.T) {
t.Parallel()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValue(config.ViperKeySelfServiceRecoveryEnabled, true),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
)
router := httprouterx.NewRouterPublic()
ts, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, httprouterx.NewRouterAdminWithPrefix())
redirTS := testhelpers.NewRedirTS(t, "already authenticated", conf)
t.Run("does redirect to default on authenticated request", func(t *testing.T) {
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, router, testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+recovery.RouteInitBrowserFlow, nil))
assert.Contains(t, res.Request.URL.String(), redirTS.URL, "%+v", res)
assert.EqualValues(t, "already authenticated", string(body))
})
t.Run("does redirect to default on authenticated request", func(t *testing.T) {
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, router, testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+recovery.RouteInitAPIFlow, nil))
assert.Contains(t, res.Request.URL.String(), recovery.RouteInitAPIFlow)
assert.EqualValues(t, text.ErrIDAlreadyLoggedIn, gjson.GetBytes(body, "error.id").Str)
assertx.EqualAsJSON(t, recovery.ErrAlreadyLoggedIn(), json.RawMessage(gjson.GetBytes(body, "error").Raw))
})
}
func TestInitFlow(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.MethodEnableConfig(recovery.RecoveryStrategyLink, true)),
configx.WithValues(testhelpers.MethodEnableConfig(recovery.RecoveryStrategyCode, true)),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
configx.WithValue(config.ViperKeySelfServiceRecoveryEnabled, true),
)
router := httprouterx.NewRouterPublic()
publicTS, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, httprouterx.NewRouterAdminWithPrefix())
recoveryTS := testhelpers.NewRecoveryUIFlowEchoServer(t, reg)
assertion := func(body []byte, isForced, isApi bool) {
if isApi {
assert.Equal(t, "api", gjson.GetBytes(body, "type").String())
} else {
assert.Equal(t, "browser", gjson.GetBytes(body, "type").String())
}
}
initAuthenticatedFlow := func(t *testing.T, isAPI, isSPA bool) (*http.Response, []byte) {
route := recovery.RouteInitBrowserFlow
if isAPI {
route = recovery.RouteInitAPIFlow
}
req := testhelpers.NewTestHTTPRequest(t, "GET", publicTS.URL+route, nil)
if isSPA {
req.Header.Set("Accept", "application/json")
}
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, router, req)
if isAPI {
assert.Len(t, res.Header.Get("Set-Cookie"), 0)
}
return res, body
}
initFlow := func(t *testing.T, isAPI bool) (*http.Response, []byte) {
route := recovery.RouteInitBrowserFlow
if isAPI {
route = recovery.RouteInitAPIFlow
}
c := publicTS.Client()
res, err := c.Get(publicTS.URL + route)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
return res, body
}
initSPAFlow := func(t *testing.T, hc *http.Client, isSPA bool) (*http.Response, []byte) {
route := recovery.RouteInitBrowserFlow
c := publicTS.Client()
req := testhelpers.NewTestHTTPRequest(t, "GET", publicTS.URL+route, nil)
if isSPA {
req.Header.Set("Accept", "application/json")
}
res, err := c.Do(req)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
return res, body
}
t.Run("flow=api", func(t *testing.T) {
t.Run("case=creates a new flow on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, true)
assert.Contains(t, res.Request.URL.String(), recovery.RouteInitAPIFlow)
assertion(body, false, true)
})
t.Run("case=fails on authenticated request", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, true, false)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assertx.EqualAsJSON(t, recovery.ErrAlreadyLoggedIn(), json.RawMessage(gjson.GetBytes(body, "error").Raw), "%s", body)
})
})
t.Run("flow=spa", func(t *testing.T) {
t.Run("case=creates a new flow on unauthenticated request", func(t *testing.T) {
res, body := initSPAFlow(t, testhelpers.NewTestClient(t), true)
assert.Contains(t, res.Request.URL.String(), recovery.RouteInitBrowserFlow)
assertion(body, false, false)
})
t.Run("case=fails on authenticated request", func(t *testing.T) {
res, body := initAuthenticatedFlow(t, false, true)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assertx.EqualAsJSON(t, recovery.ErrAlreadyLoggedIn(), json.RawMessage(gjson.GetBytes(body, "error").Raw), "%s", body)
})
})
t.Run("flow=browser", func(t *testing.T) {
t.Run("case=does not set forced flag on unauthenticated request", func(t *testing.T) {
res, body := initFlow(t, false)
assertion(body, false, false)
assert.Contains(t, res.Request.URL.String(), recoveryTS.URL)
})
t.Run("case=fails on authenticated request", func(t *testing.T) {
res, _ := initAuthenticatedFlow(t, false, false)
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceBrowserDefaultReturnTo(t.Context()).Host)
})
t.Run("case=relative redirect when self-service recovery ui is a relative URL", func(t *testing.T) {
reg.Config().MustSet(ctx, config.ViperKeySelfServiceRecoveryUI, "/recovery-ts")
assert.Regexp(
t,
"^/recovery-ts.*$",
testhelpers.GetSelfServiceRedirectLocation(t, publicTS.URL+recovery.RouteInitBrowserFlow),
)
})
t.Run("case=redirects with 303", func(t *testing.T) {
c := &http.Client{}
// don't get the reference, instead copy the values, so we don't alter the client directly.
*c = *publicTS.Client()
// prevent the redirect
c.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
req, err := http.NewRequest("GET", publicTS.URL+recovery.RouteInitBrowserFlow, nil)
require.NoError(t, err)
res, err := c.Do(req)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
// here we check that the redirect status is 303
require.Equal(t, http.StatusSeeOther, res.StatusCode)
})
})
}
func TestGetFlow(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryEnabled, true)
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(recovery.RecoveryStrategyLink),
map[string]interface{}{"enabled": true})
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(recovery.RecoveryStrategyCode),
map[string]interface{}{"enabled": true})
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
returnToTS := testhelpers.NewRedirTS(t, "", conf)
public, _ := testhelpers.NewKratosServerWithCSRF(t, reg)
_ = testhelpers.NewErrorTestServer(t, reg)
_ = testhelpers.NewRedirTS(t, "", conf)
setupRecoveryTS := func(t *testing.T, c *http.Client) *httptest.Server {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write(testhelpers.EasyGetBody(t, c, public.URL+recovery.RouteGetFlow+"?id="+r.URL.Query().Get("flow")))
require.NoError(t, err)
}))
t.Cleanup(ts.Close)
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryUI, ts.URL)
return ts
}
t.Run("case=csrf cookie missing", func(t *testing.T) {
client := testhelpers.NewTestClient(t)
setupRecoveryTS(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+recovery.RouteInitBrowserFlow)
assert.EqualValues(t, nosurfx.ErrInvalidCSRFToken().ReasonField, gjson.GetBytes(body, "error.reason").String(), "%s", body)
})
t.Run("case=valid", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
setupRecoveryTS(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+recovery.RouteInitBrowserFlow)
assert.NotEmpty(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String(), "%s", body)
assert.NotEmpty(t, gjson.GetBytes(body, "id").String(), "%s", body)
assert.Empty(t, gjson.GetBytes(body, "headers").Value(), "%s", body)
assert.Contains(t, gjson.GetBytes(body, "ui.action").String(), gjson.GetBytes(body, "id").String(), "%s", body)
assert.Contains(t, gjson.GetBytes(body, "ui.action").String(), public.URL, "%s", body)
})
t.Run("case=expired", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
setupRecoveryTS(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+recovery.RouteInitBrowserFlow)
// Expire the flow
f, err := reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(body, "id").String()))
require.NoError(t, err)
f.ExpiresAt = time.Now().Add(-time.Second)
require.NoError(t, reg.RecoveryFlowPersister().UpdateRecoveryFlow(context.Background(), f))
res, body := testhelpers.EasyGet(t, client, public.URL+recovery.RouteGetFlow+"?id="+f.ID.String())
assert.EqualValues(t, http.StatusGone, res.StatusCode)
assert.Equal(t, public.URL+recovery.RouteInitBrowserFlow, gjson.GetBytes(body, "error.details.redirect_to").String(), "%s", body)
})
t.Run("case=expired with return_to", func(t *testing.T) {
returnTo := returnToTS.URL
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnTo})
client := testhelpers.NewClientWithCookies(t)
setupRecoveryTS(t, client)
body := testhelpers.EasyGetBody(t, client, public.URL+recovery.RouteInitBrowserFlow+"?return_to="+returnTo)
// Expire the flow
f, err := reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(body, "id").String()))
require.NoError(t, err)
f.ExpiresAt = time.Now().Add(-time.Second)
require.NoError(t, reg.RecoveryFlowPersister().UpdateRecoveryFlow(context.Background(), f))
// Retrieve the flow and verify that return_to is in the response
getURL := fmt.Sprintf("%s%s?id=%s&return_to=%s", public.URL, recovery.RouteGetFlow, f.ID, returnTo)
getBody := testhelpers.EasyGetBody(t, client, getURL)
assert.Equal(t, gjson.GetBytes(getBody, "error.details.return_to").String(), returnTo)
// submit the flow but it is expired
u := public.URL + recovery.RouteSubmitFlow + "?flow=" + f.ID.String()
res, err := client.PostForm(u, url.Values{"email": {"email@ory.sh"}, "csrf_token": {f.CSRFToken}, "method": {"link"}})
require.NoError(t, err)
resBody, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
f, err = reg.RecoveryFlowPersister().GetRecoveryFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(resBody, "id").String()))
require.NoError(t, err)
assert.Equal(t, public.URL+recovery.RouteInitBrowserFlow+"?return_to="+returnTo, f.RequestURL)
})
t.Run("case=not found", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
setupRecoveryTS(t, client)
res, _ := testhelpers.EasyGet(t, client, public.URL+recovery.RouteGetFlow+"?id="+x.NewUUID().String())
assert.EqualValues(t, http.StatusNotFound, res.StatusCode)
})
}

View File

@@ -0,0 +1,134 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery
import (
"context"
"fmt"
"net/http"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"go.opentelemetry.io/otel/trace"
"github.com/ory/kratos/x/events"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/session"
"github.com/ory/kratos/ui/node"
)
type (
PreHookExecutor interface {
ExecuteRecoveryPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error
}
PreHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow) error
PostHookExecutor interface {
ExecutePostRecoveryHook(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error
}
PostHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error
HooksProvider interface {
PreRecoveryHooks(ctx context.Context) ([]PreHookExecutor, error)
PostRecoveryHooks(ctx context.Context) ([]PostHookExecutor, error)
}
)
func PostHookRecoveryExecutorNames(e []PostHookExecutor) []string {
names := make([]string, len(e))
for k, ee := range e {
names[k] = fmt.Sprintf("%T", ee)
}
return names
}
func (f PreHookExecutorFunc) ExecuteRecoveryPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
return f(w, r, a)
}
func (f PostHookExecutorFunc) ExecutePostRecoveryHook(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error {
return f(w, r, a, s)
}
type (
executorDependencies interface {
config.Provider
identity.ManagementProvider
identity.ValidationProvider
session.PersistenceProvider
HooksProvider
nosurfx.CSRFTokenGeneratorProvider
logrusx.Provider
httpx.WriterProvider
}
HookExecutor struct {
d executorDependencies
}
HookExecutorProvider interface {
RecoveryExecutor() *HookExecutor
}
)
func NewHookExecutor(d executorDependencies) *HookExecutor {
return &HookExecutor{
d: d,
}
}
func (e *HookExecutor) PostRecoveryHook(w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) error {
logger := e.d.Logger().
WithRequest(r)
if s.Identity != nil {
logger = logger.WithField("identity_id", s.Identity.ID)
}
logger.Debug("Running ExecutePostRecoveryHooks.")
hooks, err := e.d.PostRecoveryHooks(r.Context())
if err != nil {
return err
}
for k, executor := range hooks {
if err := executor.ExecutePostRecoveryHook(w, r, a, s); err != nil {
var traits identity.Traits
if s.Identity != nil {
traits = s.Identity.Traits
}
return flow.HandleHookError(w, r, a, traits, node.LinkGroup, err, e.d, e.d)
}
logger.
WithField("executor", fmt.Sprintf("%T", executor)).
WithField("executor_position", k).
WithField("executors", PostHookRecoveryExecutorNames(hooks)).
Debug("ExecutePostRecoveryHook completed successfully.")
}
trace.SpanFromContext(r.Context()).AddEvent(events.NewRecoverySucceeded(r.Context(), a.ID, s.Identity.ID, string(a.Type), a.Active.String()))
logger.Debug("Post recovery execution hooks completed successfully.")
return nil
}
func (e *HookExecutor) PreRecoveryHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
hooks, err := e.d.PreRecoveryHooks(r.Context())
if err != nil {
return err
}
for _, executor := range hooks {
if err := executor.ExecuteRecoveryPreHook(w, r, a); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,119 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery_test
import (
"context"
"net/http"
"net/url"
"testing"
"time"
"github.com/gobuffalo/httptest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/x/configx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/selfservice/strategy/code"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
)
func TestRecoveryExecutor(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
)
s := recovery.Strategies{code.NewStrategy(reg)}
newServer := func(t *testing.T, i *identity.Identity, ft flow.Type) *httptest.Server {
router := http.NewServeMux()
router.HandleFunc("GET /recovery/pre", func(w http.ResponseWriter, r *http.Request) {
a, err := recovery.NewFlow(reg, time.Minute, nosurfx.FakeCSRFToken, r, s, ft)
require.NoError(t, err)
if testhelpers.SelfServiceHookErrorHandler(t, w, r, recovery.ErrHookAbortFlow, reg.RecoveryExecutor().PreRecoveryHook(w, r, a)) {
_, _ = w.Write([]byte("ok"))
}
})
router.HandleFunc("GET /recovery/post", func(w http.ResponseWriter, r *http.Request) {
a, err := recovery.NewFlow(reg, time.Minute, nosurfx.FakeCSRFToken, r, s, ft)
require.NoError(t, err)
s, err := testhelpers.NewActiveSession(r,
reg,
i,
time.Now().UTC(),
identity.CredentialsTypeRecoveryLink,
identity.AuthenticatorAssuranceLevel1,
)
require.NoError(t, err)
a.RequestURL = x.RequestURL(r).String()
if testhelpers.SelfServiceHookErrorHandler(t, w, r, recovery.ErrHookAbortFlow, reg.RecoveryExecutor().PostRecoveryHook(w, r, a, s)) {
_, _ = w.Write([]byte("ok"))
}
})
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, ts.URL)
return ts
}
t.Run("method=PostRecoveryHook", func(t *testing.T) {
t.Run("case=pass without hooks", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
i := testhelpers.SelfServiceHookFakeIdentity(t)
ts := newServer(t, i, flow.TypeBrowser)
res, _ := testhelpers.SelfServiceMakeHookRequest(t, ts, "/recovery/post", false, url.Values{})
assert.EqualValues(t, http.StatusOK, res.StatusCode)
})
t.Run("case=pass if hooks pass", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal),
[]config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}})
i := testhelpers.SelfServiceHookFakeIdentity(t)
ts := newServer(t, i, flow.TypeBrowser)
res, _ := testhelpers.SelfServiceMakeHookRequest(t, ts, "/recovery/post", false, url.Values{})
assert.EqualValues(t, http.StatusOK, res.StatusCode)
})
t.Run("case=fail if hooks fail", func(t *testing.T) {
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal),
[]config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecutePostRecoveryHook": "abort"}`)}})
i := testhelpers.SelfServiceHookFakeIdentity(t)
ts := newServer(t, i, flow.TypeBrowser)
res, body := testhelpers.SelfServiceMakeHookRequest(t, ts, "/recovery/post", false, url.Values{})
assert.EqualValues(t, http.StatusOK, res.StatusCode)
assert.Equal(t, "", body)
})
})
for _, kind := range []flow.Type{flow.TypeBrowser, flow.TypeAPI} {
t.Run("type="+string(kind)+"/method=PreRecoveryHook", testhelpers.TestSelfServicePreHook(
config.ViperKeySelfServiceRecoveryBeforeHooks,
testhelpers.SelfServiceMakeRecoveryPreHookRequest,
func(t *testing.T) *httptest.Server {
return newServer(t, nil, kind)
},
conf,
))
}
}

View File

@@ -0,0 +1,23 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery
import (
"context"
"time"
"github.com/gofrs/uuid"
)
type (
FlowPersister interface {
CreateRecoveryFlow(context.Context, *Flow) error
GetRecoveryFlow(ctx context.Context, id uuid.UUID) (*Flow, error)
UpdateRecoveryFlow(context.Context, *Flow) error
DeleteExpiredRecoveryFlows(context.Context, time.Time, int) error
}
FlowPersistenceProvider interface {
RecoveryFlowPersister() FlowPersister
}
)

View File

@@ -0,0 +1,17 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery
import "github.com/ory/kratos/selfservice/flow"
// Recovery Flow State
//
// The state represents the state of the recovery flow.
//
// - 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.
//
// swagger:model recoveryFlowState
type State = flow.State

View File

@@ -0,0 +1,57 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package recovery
import (
"context"
"net/http"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/ui/node"
)
//swagger:enum RecoveryMethod
type RecoveryMethod string
const (
RecoveryStrategyLink RecoveryMethod = "link"
RecoveryStrategyCode RecoveryMethod = "code"
)
type (
Strategy interface {
RecoveryStrategyID() string
IsPrimary() bool
NodeGroup() node.UiNodeGroup
PopulateRecoveryMethod(*http.Request, *Flow) error
Recover(w http.ResponseWriter, r *http.Request, f *Flow) (err error)
}
Strategies []Strategy
StrategyProvider interface {
AllRecoveryStrategies() Strategies
RecoveryStrategies(ctx context.Context) Strategies
GetActiveRecoveryStrategies(ctx context.Context) (active Strategies, primary Strategy, err error)
}
)
func (s Strategies) ActiveStrategies(id string) (active Strategies, primary Strategy, err error) {
ids := make([]string, len(s))
for k, ss := range s {
ids[k] = ss.RecoveryStrategyID()
if ss.RecoveryStrategyID() == id || !ss.IsPrimary() {
active = append(active, ss)
if ss.IsPrimary() {
primary = ss
}
}
}
if primary == nil {
return nil, nil, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("unable to find strategy for %s have %v", id, ids))
}
return active, primary, nil
}

View File

@@ -0,0 +1,46 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
},
"verification": {
"via": "email"
},
"recovery": {
"via": "email"
}
}
},
"stringy": {
"type": "string"
},
"numby": {
"type": "number"
},
"booly": {
"type": "boolean"
},
"should_big_number": {
"type": "number",
"minimum": 1200
},
"should_long_string": {
"type": "string",
"minLength": 25
}
}
}
}
}

View File

@@ -0,0 +1,169 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package test
import (
"context"
"testing"
"github.com/go-faker/faker/v4"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/persistence"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/x/assertx"
"github.com/ory/x/sqlcon"
)
func TestFlowPersister(ctx context.Context, p interface {
persistence.Persister
},
) func(t *testing.T) {
clearids := func(r *recovery.Flow) {
r.ID = uuid.UUID{}
}
return func(t *testing.T) {
nid, p := testhelpers.NewNetworkUnlessExisting(t, ctx, p)
t.Run("case=should error when the recovery request does not exist", func(t *testing.T) {
_, err := p.GetRecoveryFlow(ctx, x.NewUUID())
require.Error(t, err)
})
newFlow := func(t *testing.T) *recovery.Flow {
var r recovery.Flow
require.NoError(t, faker.FakeData(&r))
clearids(&r)
r.State = flow.StateShowForm
return &r
}
t.Run("case=should create a new recovery request", func(t *testing.T) {
r := newFlow(t)
err := p.CreateRecoveryFlow(ctx, r)
require.NoError(t, err, "%#v", err)
require.Equal(t, nid, r.NID)
t.Run("fail to find on other network", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
_, err := p.GetRecoveryFlow(ctx, r.ID)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
})
})
t.Run("case=should create with set ids", func(t *testing.T) {
var r recovery.Flow
require.NoError(t, faker.FakeData(&r))
r.State = flow.StateShowForm
require.NoError(t, p.CreateRecoveryFlow(ctx, &r))
require.Equal(t, nid, r.NID)
t.Run("fail to find on other network", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
_, err := p.GetRecoveryFlow(ctx, r.ID)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
})
})
t.Run("case=should create and fetch a recovery request", func(t *testing.T) {
expected := newFlow(t)
err := p.CreateRecoveryFlow(ctx, expected)
require.NoError(t, err)
require.Equal(t, nid, expected.NID)
t.Run("fail to find on other network", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
_, err := p.GetRecoveryFlow(ctx, expected.ID)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
})
actual, err := p.GetRecoveryFlow(ctx, expected.ID)
require.NoError(t, err)
assert.EqualValues(t, expected.ID, actual.ID)
x.AssertEqualTime(t, expected.IssuedAt, actual.IssuedAt)
x.AssertEqualTime(t, expected.ExpiresAt, actual.ExpiresAt)
assert.EqualValues(t, expected.RequestURL, actual.RequestURL)
assertx.EqualAsJSON(t, expected.UI, actual.UI, "expected:\t%s\nactual:\t%s", expected.UI, actual.UI)
})
t.Run("case=should create and update a recovery request", func(t *testing.T) {
expected := newFlow(t)
expected.UI.Nodes = node.Nodes{}
expected.UI.Nodes.Append(node.NewInputField("zab", nil, node.DefaultGroup, "bar", node.WithInputAttributes(func(a *node.InputAttributes) {
a.Pattern = "baz"
})))
expected.UI.Nodes.Append(node.NewInputField("foo", nil, node.DefaultGroup, "bar", node.WithInputAttributes(func(a *node.InputAttributes) {
a.Pattern = "baz"
})))
err := p.CreateRecoveryFlow(ctx, expected)
require.NoError(t, err)
expected.UI.Action = "/new-action"
expected.UI.Nodes.Append(
node.NewInputField("zab", nil, node.DefaultGroup, "zab", node.WithInputAttributes(func(a *node.InputAttributes) {
a.Pattern = "zab"
})))
expected.RequestURL = "/new-request-url"
t.Run("fail to find on other network", func(t *testing.T) {
_, other := testhelpers.NewNetwork(t, ctx, p)
require.ErrorIs(t, other.UpdateRecoveryFlow(ctx, expected), sqlcon.ErrNoRows())
actual, err := p.GetRecoveryFlow(ctx, expected.ID)
require.NoError(t, err)
assert.NotEqual(t, "/new-request-url", actual.RequestURL)
})
require.NoError(t, p.UpdateRecoveryFlow(ctx, expected))
actual, err := p.GetRecoveryFlow(ctx, expected.ID)
require.NoError(t, err)
assert.Equal(t, "/new-action", actual.UI.Action)
assert.Equal(t, "/new-request-url", actual.RequestURL)
assertx.EqualAsJSON(t, node.Nodes{
// v0.5: {Name: "zab", Type: "zab", Pattern: "zab"},
node.NewInputField("zab", nil, node.DefaultGroup, "bar", node.WithInputAttributes(func(a *node.InputAttributes) {
a.Pattern = "baz"
})),
node.NewInputField("foo", nil, node.DefaultGroup, "bar", node.WithInputAttributes(func(a *node.InputAttributes) {
a.Pattern = "baz"
})),
// v0.5: {Name: "zab", Type: "bar", Pattern: "baz"},
node.NewInputField("zab", nil, node.DefaultGroup, "zab", node.WithInputAttributes(func(a *node.InputAttributes) {
a.Pattern = "zab"
})),
}, actual.UI.Nodes)
})
t.Run("case=should not cause data loss when updating a request without changes", func(t *testing.T) {
expected := newFlow(t)
err := p.CreateRecoveryFlow(ctx, expected)
require.NoError(t, err)
actual, err := p.GetRecoveryFlow(ctx, expected.ID)
require.NoError(t, err)
require.NoError(t, p.UpdateRecoveryFlow(ctx, actual))
actual, err = p.GetRecoveryFlow(ctx, expected.ID)
require.NoError(t, err)
assertx.EqualAsJSON(t, expected.UI, actual.UI)
})
t.Run("case=handle network reference issues", func(t *testing.T) {
})
}
}