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
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:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"code": 400,
|
||||
"message": "The request was malformed or contained invalid parameters",
|
||||
"reason": "The active verification strategy not-valid is not enabled. Please enable it in the configuration.",
|
||||
"status": "Bad Request"
|
||||
}
|
||||
175
selfservice/flow/verification/error.go
Normal file
175
selfservice/flow/verification/error.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package verification
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/x/clock"
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/logrusx"
|
||||
"github.com/ory/x/otelx"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/ory/kratos/x/events"
|
||||
|
||||
"github.com/ory/kratos/ui/node"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/x/sqlxx"
|
||||
|
||||
"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 verification hook execution")
|
||||
|
||||
type (
|
||||
errorHandlerDependencies interface {
|
||||
clock.Provider
|
||||
errorx.ManagementProvider
|
||||
httpx.WriterProvider
|
||||
logrusx.Provider
|
||||
otelx.Provider
|
||||
nosurfx.CSRFProvider
|
||||
nosurfx.CSRFTokenGeneratorProvider
|
||||
config.Provider
|
||||
FlowPersistenceProvider
|
||||
StrategyProvider
|
||||
}
|
||||
|
||||
ErrorHandlerProvider interface {
|
||||
VerificationFlowErrorHandler() *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,
|
||||
err error,
|
||||
) {
|
||||
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.verification.ErrorHandler.WriteFlowError",
|
||||
trace.WithAttributes(
|
||||
attribute.String("error", err.Error()),
|
||||
))
|
||||
r = r.WithContext(ctx)
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
logger := s.d.Logger().
|
||||
WithError(err).
|
||||
WithRequest(r).
|
||||
WithField("verification_flow", f.ToLoggerField())
|
||||
|
||||
logger.
|
||||
Info("Encountered self-service verification error.")
|
||||
|
||||
if f == nil {
|
||||
trace.SpanFromContext(r.Context()).AddEvent(events.NewVerificationFailed(r.Context(), uuid.Nil, "", "", err))
|
||||
s.forward(w, r, nil, err)
|
||||
return
|
||||
}
|
||||
span.SetAttributes(attribute.String("flow_id", f.ID.String()))
|
||||
trace.SpanFromContext(r.Context()).AddEvent(events.NewVerificationFailed(r.Context(), f.ID, string(f.Type), f.Active.String(), err))
|
||||
|
||||
if e := new(flow.ExpiredError); errors.As(err, &e) {
|
||||
strategies, _, err := s.d.VerificationStrategies(r.Context()).ActiveStrategies(f.Active.String())
|
||||
if err != nil {
|
||||
strategies, _, err = s.d.GetActiveVerificationStrategies(r.Context())
|
||||
// Can't retry the verification 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
|
||||
a, err := FromOldFlow(s.d, s.d.Config().SelfServiceFlowVerificationRequestLifespan(r.Context()),
|
||||
s.d.CSRFHandler().RegenerateToken(w, 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
|
||||
}
|
||||
|
||||
a.UI.Messages.Add(text.NewErrorValidationVerificationFlowExpired(s.d.Clock(), e.ExpiredAt))
|
||||
if err := s.d.VerificationFlowPersister().CreateVerificationFlow(r.Context(), a); err != nil {
|
||||
s.forward(w, r, a, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 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 a.Type == flow.TypeAPI || x.IsJSONRequest(r) {
|
||||
http.Redirect(w, r, urlx.CopyWithQuery(urlx.AppendPaths(s.d.Config().SelfPublicURL(r.Context()),
|
||||
RouteGetFlow), url.Values{"id": {a.ID.String()}}).String(), http.StatusSeeOther)
|
||||
} else {
|
||||
http.Redirect(w, r, a.AppendTo(s.d.Config().SelfServiceFlowVerificationUI(r.Context())).String(), http.StatusSeeOther)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := f.UI.ParseError(group, err); err != nil {
|
||||
s.forward(w, r, f, err)
|
||||
return
|
||||
}
|
||||
|
||||
f.Active = sqlxx.NullString(group)
|
||||
if err := s.d.VerificationFlowPersister().UpdateVerificationFlow(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().SelfServiceFlowVerificationUI(r.Context())).String(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
updatedFlow, innerErr := s.d.VerificationFlowPersister().GetVerificationFlow(r.Context(), f.ID)
|
||||
if innerErr != nil {
|
||||
s.forward(w, r, updatedFlow, innerErr)
|
||||
return
|
||||
}
|
||||
|
||||
s.d.Writer().WriteCode(w, r, x.RecoverStatusCode(err, 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)
|
||||
}
|
||||
}
|
||||
255
selfservice/flow/verification/error_test.go
Normal file
255
selfservice/flow/verification/error_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package verification_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
|
||||
"github.com/ory/x/clock"
|
||||
"github.com/ory/x/jsonx"
|
||||
|
||||
"github.com/ory/kratos/ui/node"
|
||||
|
||||
"github.com/gobuffalo/httptest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/x/assertx"
|
||||
"github.com/ory/x/urlx"
|
||||
|
||||
"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/verification"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
func TestHandleError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationEnabled, true)
|
||||
|
||||
public, _ := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
router := http.NewServeMux()
|
||||
ts := httptest.NewServer(router)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
testhelpers.NewVerificationUIFlowEchoServer(t, reg)
|
||||
testhelpers.NewErrorTestServer(t, reg)
|
||||
|
||||
h := reg.VerificationFlowErrorHandler()
|
||||
sdk := testhelpers.NewSDKClient(public)
|
||||
|
||||
var verificationFlow *verification.Flow
|
||||
var flowError error
|
||||
var methodName node.UiNodeGroup
|
||||
router.HandleFunc("GET /error", func(w http.ResponseWriter, r *http.Request) {
|
||||
h.WriteFlowError(w, r, verificationFlow, methodName, flowError)
|
||||
})
|
||||
|
||||
reset := func() {
|
||||
verificationFlow = nil
|
||||
flowError = nil
|
||||
methodName = ""
|
||||
}
|
||||
|
||||
newFlow := func(t *testing.T, ttl time.Duration, ft flow.Type) *verification.Flow {
|
||||
t.Helper()
|
||||
req := &http.Request{URL: urlx.ParseOrPanic("/")}
|
||||
strategies, _, err := reg.GetActiveVerificationStrategies(context.Background())
|
||||
require.NoError(t, err)
|
||||
f, err := verification.NewFlow(reg, ttl, nosurfx.FakeCSRFToken, req, strategies, ft)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(context.Background(), f))
|
||||
f, err = reg.VerificationFlowPersister().GetVerificationFlow(context.Background(), f.ID)
|
||||
require.NoError(t, err)
|
||||
return f
|
||||
}
|
||||
|
||||
expectErrorUI := func(t *testing.T) (map[string]interface{}, *http.Response) {
|
||||
t.Helper()
|
||||
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(verification.VerificationStrategyLink)
|
||||
|
||||
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(verification.VerificationStrategyLink)
|
||||
|
||||
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)
|
||||
|
||||
verificationFlow = newFlow(t, time.Minute, flow.TypeAPI)
|
||||
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
|
||||
methodName = node.UiNodeGroup(verification.VerificationStrategyLink)
|
||||
|
||||
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+verification.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.ErrorValidationVerificationFlowExpired), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), string(body))
|
||||
assert.NotEqual(t, verificationFlow.ID.String(), gjson.GetBytes(body, "id").String())
|
||||
})
|
||||
|
||||
t.Run("case=validation error", func(t *testing.T) {
|
||||
t.Cleanup(reset)
|
||||
|
||||
verificationFlow = newFlow(t, time.Minute, tc.t)
|
||||
flowError = schema.NewInvalidCredentialsError()
|
||||
methodName = node.UiNodeGroup(verification.VerificationStrategyLink)
|
||||
|
||||
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, verificationFlow.ID.String(), gjson.GetBytes(body, "id").String())
|
||||
})
|
||||
|
||||
t.Run("case=generic error", func(t *testing.T) {
|
||||
t.Cleanup(reset)
|
||||
|
||||
verificationFlow = newFlow(t, time.Minute, tc.t)
|
||||
flowError = herodot.ErrInternalServerError().WithReason("system error")
|
||||
methodName = node.UiNodeGroup(verification.VerificationStrategyLink)
|
||||
|
||||
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("flow=browser", func(t *testing.T) {
|
||||
expectVerificationUI := func(t *testing.T) (*verification.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.SelfServiceFlowVerificationUI(ctx).String()+"?flow=")
|
||||
|
||||
vf, err := reg.VerificationFlowPersister().GetVerificationFlow(context.Background(), uuid.FromStringOrNil(res.Request.URL.Query().Get("flow")))
|
||||
require.NoError(t, err)
|
||||
return vf, res
|
||||
}
|
||||
|
||||
t.Run("case=expired error", func(t *testing.T) {
|
||||
t.Cleanup(reset)
|
||||
|
||||
verificationFlow = &verification.Flow{Type: flow.TypeBrowser}
|
||||
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
|
||||
methodName = node.LinkGroup
|
||||
|
||||
lf, _ := expectVerificationUI(t)
|
||||
require.Len(t, lf.UI.Messages, 1, "%s", jsonx.TestMarshalJSONString(t, lf))
|
||||
assert.Equal(t, int(text.ErrorValidationVerificationFlowExpired), int(lf.UI.Messages[0].ID))
|
||||
})
|
||||
|
||||
t.Run("case=validation error", func(t *testing.T) {
|
||||
t.Cleanup(reset)
|
||||
|
||||
verificationFlow = newFlow(t, time.Minute, flow.TypeBrowser)
|
||||
flowError = schema.NewInvalidCredentialsError()
|
||||
methodName = node.LinkGroup
|
||||
|
||||
lf, _ := expectVerificationUI(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)
|
||||
|
||||
verificationFlow = 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=fails to retry flow if recovery strategy id is not valid", func(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
reset()
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationUse, "code")
|
||||
})
|
||||
|
||||
verificationFlow = newFlow(t, 0, flow.TypeBrowser)
|
||||
verificationFlow.Active = "not-valid"
|
||||
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
|
||||
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationUse, "not-valid")
|
||||
sse, _ := expectErrorUI(t)
|
||||
testhelpers.SnapshotTExcept(t, sse, nil)
|
||||
})
|
||||
})
|
||||
}
|
||||
45
selfservice/flow/verification/fake_strategy.go
Normal file
45
selfservice/flow/verification/fake_strategy.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package verification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
)
|
||||
|
||||
type FakeStrategy struct{}
|
||||
|
||||
var _ Strategy = new(FakeStrategy)
|
||||
var _ PrimaryStrategy = new(FakeStrategy)
|
||||
|
||||
func (f FakeStrategy) VerificationStrategyID() string {
|
||||
return "fake"
|
||||
}
|
||||
|
||||
func (f FakeStrategy) IsPrimary() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (f FakeStrategy) VerificationNodeGroup() node.UiNodeGroup {
|
||||
return "fake"
|
||||
}
|
||||
|
||||
func (f FakeStrategy) PopulateVerificationMethod(*http.Request, *Flow) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FakeStrategy) Verify(_ http.ResponseWriter, _ *http.Request, _ *Flow) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FakeStrategy) SendVerificationCode(context.Context, *Flow, *identity.Identity, identity.VerifiableAddressLike) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FakeStrategy) NodeGroup() node.UiNodeGroup {
|
||||
return "fake"
|
||||
}
|
||||
360
selfservice/flow/verification/flow.go
Normal file
360
selfservice/flow/verification/flow.go
Normal file
@@ -0,0 +1,360 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package verification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
|
||||
hydraclientgo "github.com/ory/hydra-client-go/v2"
|
||||
|
||||
"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/session"
|
||||
"github.com/ory/kratos/ui/container"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/x/clock"
|
||||
"github.com/ory/x/sqlxx"
|
||||
"github.com/ory/x/urlx"
|
||||
)
|
||||
|
||||
// A Verification Flow
|
||||
//
|
||||
// Used to verify an out-of-band communication
|
||||
// channel such as an email address or a phone number.
|
||||
//
|
||||
// For more information head over to: https://www.ory.com/docs/kratos/self-service/flows/verify-email-account-activation
|
||||
//
|
||||
// swagger:model verificationFlow
|
||||
type Flow struct {
|
||||
// ID represents the request's unique ID. When performing the verification flow, this
|
||||
// represents the id in the verify ui's query parameter: http://<selfservice.flows.verification.ui_url>?request=<id>
|
||||
//
|
||||
// type: string
|
||||
// format: uuid
|
||||
// required: true
|
||||
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 verify the address,
|
||||
// a new request has to be initiated.
|
||||
ExpiresAt time.Time `json:"expires_at" faker:"time_type" db:"expires_at"`
|
||||
|
||||
// IssuedAt is the time (UTC) when the request occurred.
|
||||
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.
|
||||
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 registration 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. verify your email)
|
||||
// - sent_email: the email has been sent to the user
|
||||
// - passed_challenge: the request was successful and the verification challenge was passed.
|
||||
//
|
||||
// required: true
|
||||
State State `json:"state" faker:"-" db:"state"`
|
||||
|
||||
// OAuth2LoginChallenge holds the login challenge originally set during the registration flow.
|
||||
OAuth2LoginChallenge sqlxx.NullString `json:"-" db:"oauth2_login_challenge"`
|
||||
OAuth2LoginChallengeParams
|
||||
|
||||
// HydraLoginRequest is the OAuth2 login request behind OAuth2LoginChallenge. It is
|
||||
// hydrated on demand when a message is sent for this flow so that courier templates
|
||||
// can brand messages per OAuth2 client. It is neither persisted nor exposed via the API.
|
||||
HydraLoginRequest *hydraclientgo.OAuth2LoginRequest `json:"-" faker:"-" db:"-"`
|
||||
|
||||
// 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"`
|
||||
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
|
||||
|
||||
// TransientPayload is used to pass data from the verification flow to hooks and email templates
|
||||
//
|
||||
// required: false
|
||||
TransientPayload json.RawMessage `json:"transient_payload,omitempty" faker:"-" db:"-"`
|
||||
}
|
||||
|
||||
type OAuth2LoginChallengeParams struct {
|
||||
// SessionID holds the session id if set from a registraton hook.
|
||||
SessionID uuid.NullUUID `json:"-" faker:"-" db:"session_id"`
|
||||
|
||||
// IdentityID holds the identity id if set from a registraton hook.
|
||||
IdentityID uuid.NullUUID `json:"-" faker:"-" db:"identity_id"`
|
||||
|
||||
// AMR contains a list of authentication methods that were used to verify the
|
||||
// session if set from a registration hook.
|
||||
AMR session.AuthenticationMethods `db:"authentication_methods" json:"-"`
|
||||
}
|
||||
|
||||
var _ flow.Flow = (*Flow)(nil)
|
||||
|
||||
// flowDependencies are the dependencies NewFlow needs to construct a
|
||||
// verification flow: the configuration (for return-to validation) and the
|
||||
// clock. The lifespan and CSRF token are passed explicitly because callers
|
||||
// vary them (regenerated CSRF tokens, conditional tokens by flow type).
|
||||
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
|
||||
}
|
||||
|
||||
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(),
|
||||
},
|
||||
CSRFToken: csrf,
|
||||
State: flow.StateChooseMethod,
|
||||
Type: ft,
|
||||
}
|
||||
if err := f.setCourierBaseURL(x.BaseURLStringFromContext(r.Context())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, strategy := range strategies {
|
||||
if ps, isPrimary := strategy.(PrimaryStrategy); isPrimary {
|
||||
f.Active = sqlxx.NullString(ps.NodeGroup())
|
||||
}
|
||||
if err := strategy.PopulateVerificationMethod(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 {
|
||||
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 NewPostHookFlow(reg flowDependencies, exp time.Duration, csrf string, r *http.Request, strategies Strategies, original flow.Flow) (*Flow, error) {
|
||||
f, err := NewFlow(reg, exp, csrf, r, strategies, original.GetType())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.TransientPayload = original.GetTransientPayload()
|
||||
requestURL, err := url.ParseRequestURI(original.GetRequestURL())
|
||||
if err != nil {
|
||||
requestURL = new(url.URL)
|
||||
}
|
||||
query := requestURL.Query()
|
||||
// we need to keep the return_to in-tact if the `after_verification_return_to` is empty
|
||||
// otherwise we take the `after_verification_return_to` query parameter over the current `return_to`
|
||||
if afterVerificationReturn := query.Get("after_verification_return_to"); afterVerificationReturn != "" {
|
||||
query.Set("return_to", afterVerificationReturn)
|
||||
}
|
||||
query.Del("after_verification_return_to")
|
||||
requestURL.RawQuery = query.Encode()
|
||||
f.RequestURL = requestURL.String()
|
||||
if t, ok := original.(flow.OAuth2ChallengeProvider); ok {
|
||||
f.OAuth2LoginChallenge = t.GetOAuth2LoginChallenge()
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (f *Flow) GetType() flow.Type { return f.Type }
|
||||
func (f *Flow) GetRequestURL() string { return f.RequestURL }
|
||||
func (Flow) TableName() string { return "selfservice_verification_flows" }
|
||||
func (f Flow) GetID() uuid.UUID { return f.ID }
|
||||
func (f *Flow) GetState() State { return f.State }
|
||||
func (Flow) GetFlowName() flow.FlowName { return flow.VerificationFlow }
|
||||
func (f *Flow) SetState(state State) { f.State = state }
|
||||
func (f *Flow) GetTransientPayload() json.RawMessage { return f.TransientPayload }
|
||||
func (f *Flow) GetOAuth2LoginChallenge() sqlxx.NullString { return f.OAuth2LoginChallenge }
|
||||
func (f *Flow) GetHydraLoginRequest() *hydraclientgo.OAuth2LoginRequest {
|
||||
return f.HydraLoginRequest
|
||||
}
|
||||
func (f *Flow) GetUI() *container.Container { return f.UI }
|
||||
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
|
||||
}
|
||||
|
||||
func (f *Flow) Valid(c clock.Clock) error {
|
||||
if f.ExpiresAt.Before(c.Now()) {
|
||||
return errors.WithStack(flow.NewFlowExpiredError(c, f.ExpiresAt))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flow) AppendTo(src *url.URL) *url.URL {
|
||||
values := src.Query()
|
||||
values.Set("flow", f.ID.String())
|
||||
return urlx.CopyWithQuery(src, values)
|
||||
}
|
||||
|
||||
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() {
|
||||
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
|
||||
}
|
||||
|
||||
// ContinueURL generates the URL to show on the continue screen after succesful verification
|
||||
//
|
||||
// It follows the following precedence:
|
||||
// 1. If a `return_to` parameter has been passed to the flow's creation, is a valid URL and it's in the `selfservice.allowed_return_urls` that URL is returned
|
||||
// 2. If `selfservice.flows.verification.after` is set, that URL is returned
|
||||
// 3. As a fallback, the `selfservice.default_browser_return_url` URL is returned
|
||||
func (f *Flow) ContinueURL(ctx context.Context, config *config.Config) *url.URL {
|
||||
flowContinueURL := config.SelfServiceFlowVerificationReturnTo(ctx, config.SelfServiceBrowserDefaultReturnTo(ctx))
|
||||
|
||||
// Parse the flows request URL
|
||||
verificationRequestURL, err := urlx.Parse(f.GetRequestURL())
|
||||
if err != nil {
|
||||
// Return flow default, or global default return URL
|
||||
return flowContinueURL
|
||||
}
|
||||
|
||||
verificationRequest := http.Request{URL: verificationRequestURL}
|
||||
|
||||
returnTo, err := redir.SecureRedirectTo(&verificationRequest, flowContinueURL,
|
||||
redir.SecureRedirectAllowSelfServiceURLs(config.SelfPublicURL(ctx)),
|
||||
redir.SecureRedirectAllowURLs(config.SelfServiceBrowserAllowedReturnToDomains(ctx)),
|
||||
)
|
||||
if err != nil {
|
||||
// an error occured return flow default, or global default return URL
|
||||
return flowContinueURL
|
||||
}
|
||||
return returnTo
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
327
selfservice/flow/verification/flow_test.go
Normal file
327
selfservice/flow/verification/flow_test.go
Normal file
@@ -0,0 +1,327 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package verification_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/x/clock"
|
||||
"github.com/ory/x/jsonx"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/selfservice/flow/verification"
|
||||
"github.com/ory/kratos/x"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/x/urlx"
|
||||
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/registration"
|
||||
)
|
||||
|
||||
func TestFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
|
||||
must := func(r *verification.Flow, err error) *verification.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 *verification.Flow
|
||||
expectErr bool
|
||||
}{
|
||||
{r: must(verification.NewFlow(reg, time.Hour, "", u, nil, flow.TypeBrowser))},
|
||||
{r: must(verification.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)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("type=return_to", func(t *testing.T) {
|
||||
_, err := verification.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 = verification.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)
|
||||
})
|
||||
|
||||
assert.EqualValues(t, flow.StateChooseMethod,
|
||||
must(verification.NewFlow(reg, time.Hour, "", u, nil, flow.TypeBrowser)).State)
|
||||
}
|
||||
|
||||
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 := &verification.Flow{Type: ft}
|
||||
assert.Equal(t, ft, r.GetType())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRequestURL(t *testing.T) {
|
||||
expectedURL := "http://foo/bar/baz"
|
||||
f := &verification.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 := verification.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 := verification.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 := verification.NewFlow(reg, time.Hour, "", r, nil, flow.TypeBrowser)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "http://localhost:4000", f.GetCourierBaseURL())
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewPostHookFlow(t *testing.T) {
|
||||
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
|
||||
u := &http.Request{URL: urlx.ParseOrPanic("http://foo/bar/baz"), Host: "foo"}
|
||||
expectReturnTo := func(t *testing.T, originalFlowRequestQueryParams url.Values, expectedReturnTo string) {
|
||||
originalFlow := registration.Flow{
|
||||
RequestURL: "http://foo.com/bar?" + originalFlowRequestQueryParams.Encode(),
|
||||
}
|
||||
t.Log(originalFlow.RequestURL)
|
||||
f, err := verification.NewPostHookFlow(reg, time.Second, "", u, nil, &originalFlow)
|
||||
require.NoError(t, err)
|
||||
u, err := urlx.Parse(f.RequestURL)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(f.RequestURL, "http://foo.com/bar?"))
|
||||
assert.Equal(t, "", u.Query().Get("after_verification_return_to"))
|
||||
assert.Equal(t, expectedReturnTo, u.Query().Get("return_to"))
|
||||
}
|
||||
|
||||
t.Run("case=after_verification_return_to supplied", func(t *testing.T) {
|
||||
expectedReturnTo := "http://foo.com/verification_callback"
|
||||
expectReturnTo(t, url.Values{"after_verification_return_to": {expectedReturnTo}}, expectedReturnTo)
|
||||
})
|
||||
|
||||
t.Run("case=return_to supplied", func(t *testing.T) {
|
||||
expectReturnTo(t, url.Values{"return_to": {"http://foo.com/original_flow_callback"}}, "http://foo.com/original_flow_callback")
|
||||
})
|
||||
|
||||
t.Run("case=return_to and after_verification_return_to supplied", func(t *testing.T) {
|
||||
expectedReturnTo := "http://foo.com/verification_callback"
|
||||
expectReturnTo(t, url.Values{
|
||||
"return_to": {"http://foo.com/original_flow_callback"},
|
||||
"after_verification_return_to": {expectedReturnTo},
|
||||
}, expectedReturnTo)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFlowEncodeJSON(t *testing.T) {
|
||||
assert.EqualValues(t, "", gjson.Get(jsonx.TestMarshalJSONString(t, &verification.Flow{RequestURL: "https://foo.bar?foo=bar"}), "return_to").String())
|
||||
assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, &verification.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String())
|
||||
assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, verification.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String())
|
||||
}
|
||||
|
||||
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) {
|
||||
oldR := (&http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}).
|
||||
WithContext(x.WithBaseURL(context.Background(), urlx.ParseOrPanic("https://customer.example.com/")))
|
||||
oldF, err := verification.NewFlow(reg, time.Hour, "csrf", oldR, nil, flow.TypeBrowser)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://customer.example.com/", oldF.GetCourierBaseURL())
|
||||
|
||||
newR := (&http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}).
|
||||
WithContext(x.WithBaseURL(context.Background(), urlx.ParseOrPanic("http://localhost:4000")))
|
||||
newF, err := verification.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 := verification.NewFlow(reg, time.Hour, "csrf", r, nil, flow.TypeBrowser)
|
||||
require.NoError(t, err)
|
||||
newF, err := verification.FromOldFlow(reg, time.Hour, oldF.CSRFToken, r, nil, oldF)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", newF.GetCourierBaseURL())
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewPostHookFlow_capturesCourierBaseURL(t *testing.T) {
|
||||
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
|
||||
|
||||
// NewPostHookFlow is what the registration → auto-verification chain
|
||||
// calls during the registration submit handler. The verification flow
|
||||
// it creates needs CourierBaseURL captured from the *triggering*
|
||||
// request's context (the registration submit, with the CNAME-aware
|
||||
// X-Ory-Original-Host set by the cloud middleware) — not from the
|
||||
// original flow's RequestURL.
|
||||
t.Run("captures from triggering request context, ignoring original flow's URL", func(t *testing.T) {
|
||||
original := registration.Flow{
|
||||
RequestURL: "https://" + "different.host/bar?return_to=https://elsewhere/path",
|
||||
}
|
||||
r := (&http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}).
|
||||
WithContext(x.WithBaseURL(context.Background(), urlx.ParseOrPanic("https://customer.example.com/")))
|
||||
f, err := verification.NewPostHookFlow(reg, time.Hour, "csrf", r, nil, &original)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://customer.example.com/", f.GetCourierBaseURL())
|
||||
})
|
||||
|
||||
t.Run("preserves http scheme through the post-hook chain", func(t *testing.T) {
|
||||
// Regression test for x.WithBaseURL no longer coercing scheme to
|
||||
// https. The Ory CLI / NextJS proxy case carries http://localhost.
|
||||
original := registration.Flow{RequestURL: "http://foo.com/bar"}
|
||||
r := (&http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}).
|
||||
WithContext(x.WithBaseURL(context.Background(), urlx.ParseOrPanic("http://localhost:4000")))
|
||||
f, err := verification.NewPostHookFlow(reg, time.Hour, "csrf", r, nil, &original)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "http://localhost:4000", f.GetCourierBaseURL())
|
||||
})
|
||||
|
||||
t.Run("empty when triggering request has no base URL", func(t *testing.T) {
|
||||
original := registration.Flow{RequestURL: "http://foo.com/bar"}
|
||||
r := &http.Request{URL: urlx.ParseOrPanic("http://foo/bar"), Host: "foo"}
|
||||
f, err := verification.NewPostHookFlow(reg, time.Hour, "csrf", r, nil, &original)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", f.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 := verification.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 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"}
|
||||
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 := verification.NewFlow(reg, 0, "csrf", &r, nil, ft)
|
||||
require.NoError(t, err)
|
||||
nf, err := verification.FromOldFlow(reg, time.Duration(time.Hour), f.CSRFToken, &r, nil, f)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, flow.TypeBrowser, nf.Type)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinueURL(t *testing.T) {
|
||||
const globalReturnTo = "https://ory.sh/global-return-to"
|
||||
const localReturnTo = "https://ory.sh/local-return-to"
|
||||
const flowReturnTo = "https://ory.sh/flow-return-to"
|
||||
|
||||
for _, tc := range []struct {
|
||||
desc string
|
||||
prep func(conf *config.Config)
|
||||
requestURL string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
desc: "return_to has precedence over global return to",
|
||||
prep: func(conf *config.Config) {
|
||||
conf.MustSet(context.Background(), config.ViperKeyURLsAllowedReturnToDomains, []string{localReturnTo})
|
||||
},
|
||||
requestURL: fmt.Sprintf("http://kratos:4433/verification?return_to=%s", localReturnTo),
|
||||
expect: localReturnTo,
|
||||
},
|
||||
{
|
||||
desc: "with return_to not allowed",
|
||||
requestURL: fmt.Sprintf("http://kratos:4433/verification?return_to=%s", localReturnTo),
|
||||
expect: globalReturnTo,
|
||||
},
|
||||
{
|
||||
desc: "with invalid request url",
|
||||
requestURL: string([]byte{0x7f}), // 0x7f is an ASCII control char, and fails URL validation
|
||||
expect: globalReturnTo,
|
||||
},
|
||||
{
|
||||
desc: "flow return to has precedence over global return to",
|
||||
prep: func(conf *config.Config) {
|
||||
conf.MustSet(context.Background(), config.ViperKeySelfServiceVerificationBrowserDefaultReturnTo, flowReturnTo)
|
||||
},
|
||||
requestURL: "http://kratos:4433/verification",
|
||||
expect: flowReturnTo,
|
||||
},
|
||||
{
|
||||
desc: "return_to has precedence over flow return to",
|
||||
prep: func(conf *config.Config) {
|
||||
conf.MustSet(context.Background(), config.ViperKeySelfServiceVerificationBrowserDefaultReturnTo, flowReturnTo)
|
||||
conf.MustSet(context.Background(), config.ViperKeyURLsAllowedReturnToDomains, []string{localReturnTo})
|
||||
},
|
||||
requestURL: fmt.Sprintf("http://kratos:4433/verification?return_to=%s", localReturnTo),
|
||||
expect: localReturnTo,
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%s", tc.desc), func(t *testing.T) {
|
||||
conf := pkg.NewConfigurationWithDefaults(t)
|
||||
conf.MustSet(context.Background(), config.ViperKeySelfServiceBrowserDefaultReturnTo, globalReturnTo)
|
||||
if tc.prep != nil {
|
||||
tc.prep(conf)
|
||||
}
|
||||
flow := verification.Flow{
|
||||
RequestURL: tc.requestURL,
|
||||
}
|
||||
|
||||
url := flow.ContinueURL(context.Background(), conf)
|
||||
require.NotNil(t, url)
|
||||
|
||||
require.Equal(t, tc.expect, url.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
542
selfservice/flow/verification/handler.go
Normal file
542
selfservice/flow/verification/handler.go
Normal file
@@ -0,0 +1,542 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package verification
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/hydra"
|
||||
"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/logrusx"
|
||||
"github.com/ory/x/sqlcon"
|
||||
"github.com/ory/x/urlx"
|
||||
)
|
||||
|
||||
const (
|
||||
RouteInitBrowserFlow = "/self-service/verification/browser"
|
||||
RouteInitAPIFlow = "/self-service/verification/api"
|
||||
RouteGetFlow = "/self-service/verification/flows"
|
||||
|
||||
RouteSubmitFlow = "/self-service/verification"
|
||||
)
|
||||
|
||||
type (
|
||||
HandlerProvider interface {
|
||||
VerificationHandler() *Handler
|
||||
}
|
||||
handlerDependencies interface {
|
||||
clock.Provider
|
||||
errorx.ManagementProvider
|
||||
identity.ManagementProvider
|
||||
identity.PrivilegedPoolProvider
|
||||
config.Provider
|
||||
hydra.Provider
|
||||
session.PersistenceProvider
|
||||
session.ManagementProvider
|
||||
|
||||
nosurfx.CSRFTokenGeneratorProvider
|
||||
httpx.WriterProvider
|
||||
nosurfx.CSRFProvider
|
||||
logrusx.Provider
|
||||
|
||||
FlowPersistenceProvider
|
||||
ErrorHandlerProvider
|
||||
StrategyProvider
|
||||
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)
|
||||
|
||||
public.GET(RouteInitBrowserFlow, h.createBrowserVerificationFlow)
|
||||
public.GET(RouteInitAPIFlow, h.createNativeVerificationFlow)
|
||||
public.GET(RouteGetFlow, h.getVerificationFlow)
|
||||
|
||||
public.POST(RouteSubmitFlow, h.updateVerificationFlow)
|
||||
public.GET(RouteSubmitFlow, h.updateVerificationFlow)
|
||||
}
|
||||
|
||||
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.POST(RouteSubmitFlow, redir.RedirectToPublicRoute(h.d))
|
||||
admin.GET(RouteSubmitFlow, redir.RedirectToPublicRoute(h.d))
|
||||
}
|
||||
|
||||
type FlowOption func(f *Flow)
|
||||
|
||||
func WithFlowReturnTo(returnTo string) FlowOption {
|
||||
return func(f *Flow) {
|
||||
f.ReturnTo = returnTo
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) NewVerificationFlow(w http.ResponseWriter, r *http.Request, ft flow.Type, opts ...FlowOption) (*Flow, error) {
|
||||
strategies, _, err := h.d.GetActiveVerificationStrategies(r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := NewFlow(h.d, h.d.Config().SelfServiceFlowVerificationRequestLifespan(r.Context()), h.d.GenerateCSRFToken(r), r, strategies, ft)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(f)
|
||||
}
|
||||
|
||||
if err := h.d.VerificationExecutor().PreVerificationHook(w, r, f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := h.d.VerificationFlowPersister().CreateVerificationFlow(r.Context(), f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Create Verification Flow Parameters for Native Apps
|
||||
//
|
||||
// swagger:parameters createNativeVerificationFlow
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type createNativeVerificationFlow struct {
|
||||
// A URL contained in the return_to key of the verification flow.
|
||||
// This piece of data has no effect on the actual logic of the flow and is purely informational.
|
||||
//
|
||||
// in: query
|
||||
ReturnTo string `json:"return_to"`
|
||||
}
|
||||
|
||||
// swagger:route GET /self-service/verification/api frontend createNativeVerificationFlow
|
||||
//
|
||||
// # Create Verification Flow for Native Apps
|
||||
//
|
||||
// This endpoint initiates a verification flow for API clients such as mobile devices, smart TVs, and so on.
|
||||
//
|
||||
// To fetch an existing verification flow call `/self-service/verification/flows?flow=<flow_id>`.
|
||||
//
|
||||
// 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 Email and Phone Verification Documentation](https://www.ory.com/docs/kratos/self-service/flows/verify-email-account-activation).
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: verificationFlow
|
||||
// 400: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-public-medium
|
||||
func (h *Handler) createNativeVerificationFlow(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.d.Config().SelfServiceFlowVerificationEnabled(r.Context()) {
|
||||
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Verification is not allowed because it was disabled.")))
|
||||
return
|
||||
}
|
||||
|
||||
req, err := h.NewVerificationFlow(w, r, flow.TypeAPI)
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Writer().Write(w, r, req)
|
||||
}
|
||||
|
||||
// Create Browser Verification Flow Parameters
|
||||
//
|
||||
// swagger:parameters createBrowserVerificationFlow
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type createBrowserVerificationFlow struct {
|
||||
// The URL to return the browser to after the flow was completed.
|
||||
//
|
||||
// in: query
|
||||
ReturnTo string `json:"return_to"`
|
||||
}
|
||||
|
||||
// swagger:route GET /self-service/verification/browser frontend createBrowserVerificationFlow
|
||||
//
|
||||
// # Create Verification Flow for Browser Clients
|
||||
//
|
||||
// This endpoint initializes a browser-based account verification flow. Once initialized, the browser will be redirected to
|
||||
// `selfservice.flows.verification.ui_url` with the flow ID set as the query parameter `?flow=`.
|
||||
//
|
||||
// If this endpoint is called via an AJAX request, the response contains the recovery flow without any redirects.
|
||||
//
|
||||
// This endpoint is NOT INTENDED for API clients and only works with browsers (Chrome, Firefox, ...).
|
||||
//
|
||||
// More information can be found at [Ory Kratos Email and Phone Verification Documentation](https://www.ory.com/docs/kratos/self-service/flows/verify-email-account-activation).
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: verificationFlow
|
||||
// 303: emptyResponse
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-public-medium
|
||||
func (h *Handler) createBrowserVerificationFlow(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.d.Config().SelfServiceFlowVerificationEnabled(r.Context()) {
|
||||
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Verification is not allowed because it was disabled.")))
|
||||
return
|
||||
}
|
||||
|
||||
req, err := h.NewVerificationFlow(w, r, flow.TypeBrowser)
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
redirTo := req.AppendTo(h.d.Config().SelfServiceFlowVerificationUI(r.Context())).String()
|
||||
x.SendFlowCompletedAsRedirectOrJSON(w, r, h.d.Writer(), req, redirTo)
|
||||
}
|
||||
|
||||
// Get Verification Flow Parameters
|
||||
//
|
||||
// swagger:parameters getVerificationFlow
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type getVerificationFlow struct {
|
||||
// The Flow ID
|
||||
//
|
||||
// The value for this parameter comes from `request` URL Query parameter sent to your
|
||||
// application (e.g. `/verification?flow=abcde`).
|
||||
//
|
||||
// required: true
|
||||
// in: query
|
||||
FlowID string `json:"id"`
|
||||
|
||||
// HTTP Cookies
|
||||
//
|
||||
// When using the SDK on the server side you must include the HTTP Cookie Header
|
||||
// originally sent to your HTTP handler here.
|
||||
//
|
||||
// in: header
|
||||
// name: Cookie
|
||||
Cookie string `json:"cookie"`
|
||||
}
|
||||
|
||||
// swagger:route GET /self-service/verification/flows frontend getVerificationFlow
|
||||
//
|
||||
// # Get Verification Flow
|
||||
//
|
||||
// This endpoint returns a verification 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.getVerificationFlow(req.header('cookie'), req.query['flow'])
|
||||
//
|
||||
// res.render('verification', flow)
|
||||
// })
|
||||
// ```
|
||||
//
|
||||
// More information can be found at [Ory Kratos Email and Phone Verification Documentation](https://www.ory.com/docs/kratos/self-service/flows/verify-email-account-activation).
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: verificationFlow
|
||||
// 403: errorGeneric
|
||||
// 404: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-public-high
|
||||
func (h *Handler) getVerificationFlow(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.d.Config().SelfServiceFlowVerificationEnabled(r.Context()) {
|
||||
h.d.SelfServiceErrorManager().Forward(r.Context(), w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Verification is not allowed because it was disabled.")))
|
||||
return
|
||||
}
|
||||
|
||||
rid := x.ParseUUID(r.URL.Query().Get("id"))
|
||||
req, err := h.d.VerificationFlowPersister().GetVerificationFlow(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 req.Type == flow.TypeBrowser && !nosurf.VerifyToken(h.d.GenerateCSRFToken(r), req.CSRFToken) {
|
||||
h.d.Writer().WriteError(w, r, nosurfx.CSRFErrorReason(r, h.d))
|
||||
return
|
||||
}
|
||||
|
||||
if req.ExpiresAt.Before(h.d.Clock().Now().UTC()) {
|
||||
if req.Type == flow.TypeBrowser {
|
||||
redirectURL := flow.GetFlowExpiredRedirectURL(r.Context(), h.d.Config(), RouteInitBrowserFlow, req.ReturnTo)
|
||||
|
||||
h.d.Writer().WriteError(w, r, errors.WithStack(nosurfx.ErrGone().
|
||||
WithReason("The verification flow has expired. Redirect the user to the verification flow init endpoint to initialize a new verification flow.").
|
||||
WithDetail("redirect_to", redirectURL.String()).
|
||||
WithDetail("return_to", req.ReturnTo)))
|
||||
return
|
||||
}
|
||||
h.d.Writer().WriteError(w, r, errors.WithStack(nosurfx.ErrGone().
|
||||
WithReason("The verification flow has expired. Call the verification flow init API endpoint to initialize a new verification flow.").
|
||||
WithDetail("api", urlx.AppendPaths(h.d.Config().SelfPublicURL(r.Context()), RouteInitAPIFlow).String())))
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Writer().Write(w, r, req)
|
||||
}
|
||||
|
||||
// Update Verification Flow Parameters
|
||||
//
|
||||
// swagger:parameters updateVerificationFlow
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type updateVerificationFlow struct {
|
||||
// The Verification Flow ID
|
||||
//
|
||||
// The value for this parameter comes from `flow` URL Query parameter sent to your
|
||||
// application (e.g. `/verification?flow=abcde`).
|
||||
//
|
||||
// required: true
|
||||
// in: query
|
||||
Flow string `json:"flow"`
|
||||
|
||||
// Verification Token
|
||||
//
|
||||
// The verification token which completes the verification 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 updateVerificationFlowBody
|
||||
|
||||
// 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 Verification Flow Request Body
|
||||
//
|
||||
// swagger:model updateVerificationFlowBody
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type updateVerificationFlowBody struct{}
|
||||
|
||||
// swagger:route POST /self-service/verification frontend updateVerificationFlow
|
||||
//
|
||||
// # Complete Verification Flow
|
||||
//
|
||||
// Use this endpoint to complete a verification 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 verification 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 Verification UI URL with the Verification Flow ID appended.
|
||||
// - `sent_email` is the success state after `choose_method` when using the `link` method and allows the user to request another verification 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 verification 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 Verification UI URL with
|
||||
// a new Verification Flow ID which contains an error message that the verification link was invalid.
|
||||
//
|
||||
// More information can be found at [Ory Kratos Email and Phone Verification Documentation](https://www.ory.com/docs/kratos/self-service/flows/verify-email-account-activation).
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
// - application/x-www-form-urlencoded
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: verificationFlow
|
||||
// 303: emptyResponse
|
||||
// 400: verificationFlow
|
||||
// 410: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-public-low
|
||||
func (h *Handler) updateVerificationFlow(w http.ResponseWriter, r *http.Request) {
|
||||
rid, err := flow.GetFlowID(r)
|
||||
if err != nil {
|
||||
h.d.VerificationFlowErrorHandler().WriteFlowError(w, r, nil, node.DefaultGroup, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
f, err := h.d.VerificationFlowPersister().GetVerificationFlow(ctx, rid)
|
||||
if errors.Is(err, sqlcon.ErrNoRows()) {
|
||||
h.d.VerificationFlowErrorHandler().WriteFlowError(w, r, nil, node.DefaultGroup, errors.WithStack(herodot.ErrNotFound().WithReasonf("The verification request could not be found. Please restart the flow.")))
|
||||
return
|
||||
} else if err != nil {
|
||||
h.d.VerificationFlowErrorHandler().WriteFlowError(w, r, nil, node.DefaultGroup, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := f.Valid(h.d.Clock()); err != nil {
|
||||
h.d.VerificationFlowErrorHandler().WriteFlowError(w, r, f, node.DefaultGroup, err)
|
||||
return
|
||||
}
|
||||
|
||||
var g node.UiNodeGroup
|
||||
var found bool
|
||||
for _, ss := range h.d.AllVerificationStrategies() {
|
||||
// If a primary strategy is set, but it does not match the current strategy, that strategy is not responsible anyways.
|
||||
if ps, isPrimary := ss.(PrimaryStrategy); isPrimary && f.Active.String() != "" && f.Active.String() != ps.VerificationStrategyID() {
|
||||
continue
|
||||
}
|
||||
|
||||
err := ss.Verify(w, r, f)
|
||||
if errors.Is(err, flow.ErrStrategyNotResponsible) {
|
||||
continue
|
||||
} else if errors.Is(err, flow.ErrCompletedByStrategy) {
|
||||
return
|
||||
} else if err != nil {
|
||||
h.d.VerificationFlowErrorHandler().WriteFlowError(w, r, f, ss.NodeGroup(), err)
|
||||
return
|
||||
}
|
||||
|
||||
found = true
|
||||
g = ss.NodeGroup()
|
||||
break
|
||||
}
|
||||
|
||||
if !found {
|
||||
h.d.VerificationFlowErrorHandler().WriteFlowError(w, r, f, node.DefaultGroup, errors.WithStack(schema.NewNoVerificationStrategyResponsible()))
|
||||
return
|
||||
}
|
||||
|
||||
// API flows can receive requests from the browser, if the link strategy is used.
|
||||
// However, x.IsBrowserRequest only checks for form submissions, not JSON requests made from a browser context
|
||||
if x.IsBrowserRequest(r) || (f.Type == flow.TypeBrowser && x.IsJSONRequest(r)) {
|
||||
// Special case: If we ended up here through a OAuth2 login challenge, we need to accept the login request
|
||||
// and redirect back to the OAuth2 provider.
|
||||
if flow.HasReachedState(flow.StatePassedChallenge, f.State) && f.OAuth2LoginChallenge.String() != "" {
|
||||
if !f.IdentityID.Valid || !f.SessionID.Valid {
|
||||
h.d.VerificationFlowErrorHandler().WriteFlowError(w, r, f, node.DefaultGroup,
|
||||
herodot.ErrBadRequest().WithReasonf("No session was found for this flow. Please retry the authentication."))
|
||||
return
|
||||
}
|
||||
|
||||
callbackURL, err := h.d.Hydra().AcceptLoginRequest(ctx,
|
||||
hydra.AcceptLoginRequestParams{
|
||||
LoginChallenge: string(f.OAuth2LoginChallenge),
|
||||
IdentityID: f.IdentityID.UUID.String(),
|
||||
SessionID: f.SessionID.UUID.String(),
|
||||
AuthenticationMethods: f.AMR,
|
||||
})
|
||||
if err != nil {
|
||||
h.d.VerificationFlowErrorHandler().WriteFlowError(w, r, f, node.DefaultGroup, err)
|
||||
return
|
||||
}
|
||||
|
||||
sess, err := h.d.SessionPersister().GetSession(ctx, f.SessionID.UUID, session.ExpandDefault)
|
||||
if err != nil {
|
||||
h.d.VerificationFlowErrorHandler().WriteFlowError(w, r, f, node.DefaultGroup, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.d.SessionManager().IssueCookie(ctx, w, r, sess)
|
||||
if err != nil {
|
||||
h.d.VerificationFlowErrorHandler().WriteFlowError(w, r, f, node.DefaultGroup, err)
|
||||
return
|
||||
}
|
||||
|
||||
if x.IsJSONRequest(r) {
|
||||
// This intentionally works differently than the "form browser" flow,
|
||||
// as it _does_ show the `verification success` UI, but the "Continue"
|
||||
// button contains the link to the OAuth2 provider with the `login_verifier`.
|
||||
continueNode := f.UI.Nodes.Find("continue")
|
||||
if continueNode != nil {
|
||||
if attr, ok := continueNode.Attributes.(*node.AnchorAttributes); ok {
|
||||
attr.HREF = callbackURL
|
||||
if err := h.d.VerificationFlowPersister().UpdateVerificationFlow(ctx, f); err != nil {
|
||||
h.d.VerificationFlowErrorHandler().WriteFlowError(w, r, f, node.DefaultGroup, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Writer().Write(w, r, f)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// The flow does not have the `continue` node, which is an unknown state.
|
||||
// This should never happen.
|
||||
} else {
|
||||
http.Redirect(w, r, callbackURL, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
} else if x.IsBrowserRequest(r) {
|
||||
http.Redirect(w, r, f.AppendTo(h.d.Config().SelfServiceFlowVerificationUI(ctx)).String(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updatedFlow, err := h.d.VerificationFlowPersister().GetVerificationFlow(ctx, f.ID)
|
||||
if err != nil {
|
||||
h.d.VerificationFlowErrorHandler().WriteFlowError(w, r, f, g, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Writer().Write(w, r, updatedFlow)
|
||||
}
|
||||
317
selfservice/flow/verification/handler_test.go
Normal file
317
selfservice/flow/verification/handler_test.go
Normal file
@@ -0,0 +1,317 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package verification_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
|
||||
"github.com/gobuffalo/httptest"
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/hydra"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/verification"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/ui/container"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
func TestGetFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationEnabled, true)
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(verification.VerificationStrategyLink),
|
||||
map[string]interface{}{"enabled": true})
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
|
||||
|
||||
public, _ := testhelpers.NewKratosServerWithCSRF(t, reg)
|
||||
_ = testhelpers.NewErrorTestServer(t, reg)
|
||||
_ = testhelpers.NewRedirTS(t, "", conf)
|
||||
|
||||
setupVerificationUI := 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+verification.RouteGetFlow+"?id="+r.URL.Query().Get("flow")))
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationUI, ts.URL)
|
||||
return ts
|
||||
}
|
||||
|
||||
assertFlowPayload := func(t *testing.T, body []byte, isApi bool) {
|
||||
if isApi {
|
||||
assert.Equal(t, "api", gjson.GetBytes(body, "type").String(), "%s", body)
|
||||
assert.Empty(t, gjson.GetBytes(body, "ui.fields.#(attributes.name==csrf_token).attributes.value").String(), "%s", body)
|
||||
} else {
|
||||
assert.Equal(t, "browser", gjson.GetBytes(body, "type").String(), "%s", body)
|
||||
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=valid", func(t *testing.T) {
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
client := testhelpers.NewClientWithCookies(t)
|
||||
_ = setupVerificationUI(t, client)
|
||||
res, body := testhelpers.EasyGet(t, client, public.URL+verification.RouteInitBrowserFlow)
|
||||
require.NotEqualValues(t, res.Request.URL.String(), public.URL+verification.RouteInitBrowserFlow)
|
||||
assertFlowPayload(t, body, false)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
client := testhelpers.NewClientWithCookies(t)
|
||||
_ = setupVerificationUI(t, client)
|
||||
res, body := testhelpers.EasyGetJSON(t, client, public.URL+verification.RouteInitBrowserFlow)
|
||||
require.EqualValues(t, res.Request.URL.String(), public.URL+verification.RouteInitBrowserFlow)
|
||||
assertFlowPayload(t, body, false)
|
||||
})
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
client := testhelpers.NewClientWithCookies(t)
|
||||
_ = setupVerificationUI(t, client)
|
||||
res, body := testhelpers.EasyGet(t, client, public.URL+verification.RouteInitAPIFlow)
|
||||
assert.Len(t, res.Header.Get("Set-Cookie"), 0)
|
||||
assertFlowPayload(t, body, true)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=csrf cookie missing", func(t *testing.T) {
|
||||
client := testhelpers.NewTestClient(t)
|
||||
_ = setupVerificationUI(t, client)
|
||||
body := testhelpers.EasyGetBody(t, client, public.URL+verification.RouteInitBrowserFlow)
|
||||
|
||||
assert.EqualValues(t, nosurfx.ErrInvalidCSRFToken().ReasonField, gjson.GetBytes(body, "error.reason").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=expired", func(t *testing.T) {
|
||||
client := testhelpers.NewClientWithCookies(t)
|
||||
_ = setupVerificationUI(t, client)
|
||||
body := testhelpers.EasyGetBody(t, client, public.URL+verification.RouteInitBrowserFlow)
|
||||
|
||||
// Expire the flow
|
||||
f, err := reg.VerificationFlowPersister().GetVerificationFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(body, "id").String()))
|
||||
require.NoError(t, err)
|
||||
f.ExpiresAt = time.Now().Add(-time.Second)
|
||||
require.NoError(t, reg.VerificationFlowPersister().UpdateVerificationFlow(context.Background(), f))
|
||||
|
||||
res, body := testhelpers.EasyGet(t, client, public.URL+verification.RouteGetFlow+"?id="+f.ID.String())
|
||||
assert.EqualValues(t, http.StatusGone, res.StatusCode)
|
||||
assert.Equal(t, public.URL+verification.RouteInitBrowserFlow, gjson.GetBytes(body, "error.details.redirect_to").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=expired with return_to", func(t *testing.T) {
|
||||
returnTo := "https://www.ory.com"
|
||||
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnTo})
|
||||
|
||||
client := testhelpers.NewClientWithCookies(t)
|
||||
_ = setupVerificationUI(t, client)
|
||||
body := testhelpers.EasyGetBody(t, client, public.URL+verification.RouteInitBrowserFlow+"?return_to="+returnTo)
|
||||
|
||||
// Expire the flow
|
||||
f, err := reg.VerificationFlowPersister().GetVerificationFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(body, "id").String()))
|
||||
require.NoError(t, err)
|
||||
f.ExpiresAt = time.Now().Add(-time.Second)
|
||||
require.NoError(t, reg.VerificationFlowPersister().UpdateVerificationFlow(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, verification.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 + verification.RouteSubmitFlow + "?flow=" + f.ID.String()
|
||||
res, err := client.PostForm(u, url.Values{"method": {"link"}, "csrf_token": {f.CSRFToken}, "email": {"email@ory.sh"}})
|
||||
require.NoError(t, err)
|
||||
resBody, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, res.Body.Close())
|
||||
|
||||
f, err = reg.VerificationFlowPersister().GetVerificationFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(resBody, "id").String()))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, public.URL+verification.RouteInitBrowserFlow+"?return_to="+returnTo, f.RequestURL)
|
||||
})
|
||||
|
||||
t.Run("case=relative redirect when self-service verification ui is a relative URL", func(t *testing.T) {
|
||||
ts, _ := testhelpers.NewKratosServer(t, reg)
|
||||
reg.Config().MustSet(ctx, config.ViperKeySelfServiceVerificationUI, "/verification-ts")
|
||||
assert.Regexp(
|
||||
t,
|
||||
"^/verification-ts.*$",
|
||||
testhelpers.GetSelfServiceRedirectLocation(t, ts.URL+verification.RouteInitBrowserFlow),
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("case=not found", func(t *testing.T) {
|
||||
client := testhelpers.NewClientWithCookies(t)
|
||||
_ = setupVerificationUI(t, client)
|
||||
|
||||
res, _ := testhelpers.EasyGet(t, client, public.URL+verification.RouteGetFlow+"?id="+x.NewUUID().String())
|
||||
assert.EqualValues(t, http.StatusNotFound, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("case=redirects with 303", func(t *testing.T) {
|
||||
ts, _ := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
// prevent the redirect
|
||||
ts.Client().CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
req, err := http.NewRequest("GET", ts.URL+verification.RouteInitBrowserFlow, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := ts.Client().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 TestPostFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
reg.WithSelfserviceStrategies(t, []any{&verification.FakeStrategy{}})
|
||||
reg.SetHydra(hydra.NewFake())
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationEnabled, true)
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/identity.schema.json")
|
||||
|
||||
public, _ := testhelpers.NewKratosServerWithCSRF(t, reg)
|
||||
_ = testhelpers.NewErrorTestServer(t, reg)
|
||||
_ = testhelpers.NewRedirTS(t, "", conf)
|
||||
|
||||
t.Run("client=browser/case=valid", func(t *testing.T) {
|
||||
f := &verification.Flow{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
Type: "browser",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
IssuedAt: time.Now(),
|
||||
State: flow.StateChooseMethod,
|
||||
}
|
||||
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f))
|
||||
|
||||
client := testhelpers.NewNoRedirectClientWithCookies(t)
|
||||
|
||||
u := public.URL + verification.RouteSubmitFlow + "?flow=" + f.ID.String()
|
||||
resp, err := client.PostForm(u, url.Values{"method": {"fake"}})
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, http.StatusSeeOther, resp.StatusCode)
|
||||
assert.Equal(t, conf.SelfServiceFlowVerificationUI(ctx).String()+"?flow="+f.ID.String(), resp.Header.Get("Location"))
|
||||
})
|
||||
|
||||
t.Run("client=spa/case=valid", func(t *testing.T) {
|
||||
f := &verification.Flow{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
Type: "browser",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
IssuedAt: time.Now(),
|
||||
State: flow.StateChooseMethod,
|
||||
}
|
||||
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f))
|
||||
|
||||
client := testhelpers.NewClientWithCookies(t)
|
||||
|
||||
u := public.URL + verification.RouteSubmitFlow + "?flow=" + f.ID.String()
|
||||
req, err := http.NewRequest("POST", u, strings.NewReader(`{"method": "fake"}`))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("suite=with OIDC login challenge", func(t *testing.T) {
|
||||
createFlow := func(t *testing.T) *verification.Flow {
|
||||
t.Helper()
|
||||
s := testhelpers.CreateSession(t, reg)
|
||||
|
||||
f := &verification.Flow{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
Type: "browser",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
IssuedAt: time.Now(),
|
||||
OAuth2LoginChallenge: hydra.FakeValidLoginChallenge,
|
||||
OAuth2LoginChallengeParams: verification.OAuth2LoginChallengeParams{
|
||||
SessionID: uuid.NullUUID{UUID: s.ID, Valid: true},
|
||||
IdentityID: uuid.NullUUID{UUID: s.IdentityID, Valid: true},
|
||||
AMR: s.AMR,
|
||||
},
|
||||
UI: &container.Container{
|
||||
Action: "http://action",
|
||||
Nodes: []*node.Node{node.NewAnchorField("continue", "https://ory.sh", node.CodeGroup, text.NewInfoNodeLabelContinue())},
|
||||
},
|
||||
State: flow.StatePassedChallenge,
|
||||
}
|
||||
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f))
|
||||
return f
|
||||
}
|
||||
t.Run("client=browser/case=succeeds with a session", func(t *testing.T) {
|
||||
f := createFlow(t)
|
||||
client := testhelpers.NewNoRedirectClientWithCookies(t)
|
||||
|
||||
u := public.URL + verification.RouteSubmitFlow + "?flow=" + f.ID.String()
|
||||
resp, err := client.PostForm(u, url.Values{"method": {"fake"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
assert.Equal(t, hydra.FakePostLoginURL, resp.Header.Get("Location"))
|
||||
})
|
||||
t.Run("client=spa/case=succeeds with a session", func(t *testing.T) {
|
||||
f := createFlow(t)
|
||||
client := testhelpers.NewNoRedirectClientWithCookies(t)
|
||||
|
||||
u := public.URL + verification.RouteSubmitFlow + "?flow=" + f.ID.String()
|
||||
resp, err := client.Post(u, "application/json", strings.NewReader(`{"method": "fake"}`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := x.MustReadAll(resp.Body)
|
||||
assert.Equal(t, hydra.FakePostLoginURL, gjson.GetBytes(body, "ui.nodes.#(attributes.id==continue).attributes.href").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=fails without a session", func(t *testing.T) {
|
||||
client := testhelpers.NewClientWithCookies(t)
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := w.Write(testhelpers.EasyGetBody(t, client, public.URL+verification.RouteGetFlow+"?id="+r.URL.Query().Get("flow")))
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationUI, ts.URL)
|
||||
|
||||
f := &verification.Flow{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
Type: "browser",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
IssuedAt: time.Now(),
|
||||
OAuth2LoginChallenge: hydra.FakeValidLoginChallenge,
|
||||
State: flow.StateChooseMethod,
|
||||
}
|
||||
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f))
|
||||
|
||||
u := public.URL + verification.RouteSubmitFlow + "?flow=" + f.ID.String()
|
||||
resp, err := client.PostForm(u, url.Values{"method": {"fake"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, f.ID.String(), resp.Request.URL.Query().Get("flow"))
|
||||
})
|
||||
})
|
||||
}
|
||||
130
selfservice/flow/verification/hook.go
Normal file
130
selfservice/flow/verification/hook.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package verification
|
||||
|
||||
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 {
|
||||
ExecuteVerificationPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error
|
||||
}
|
||||
PreHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow) error
|
||||
|
||||
PostHookExecutor interface {
|
||||
ExecutePostVerificationHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error
|
||||
}
|
||||
PostHookExecutorFunc func(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error
|
||||
|
||||
HooksProvider interface {
|
||||
PostVerificationHooks(ctx context.Context) ([]PostHookExecutor, error)
|
||||
PreVerificationHooks(ctx context.Context) ([]PreHookExecutor, error)
|
||||
}
|
||||
)
|
||||
|
||||
func PostHookVerificationExecutorNames(e []PostHookExecutor) []string {
|
||||
names := make([]string, len(e))
|
||||
for k, ee := range e {
|
||||
names[k] = fmt.Sprintf("%T", ee)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (f PreHookExecutorFunc) ExecuteVerificationPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
|
||||
return f(w, r, a)
|
||||
}
|
||||
|
||||
func (f PostHookExecutorFunc) ExecutePostVerificationHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error {
|
||||
return f(w, r, a, i)
|
||||
}
|
||||
|
||||
type (
|
||||
executorDependencies interface {
|
||||
config.Provider
|
||||
identity.ManagementProvider
|
||||
identity.ValidationProvider
|
||||
session.PersistenceProvider
|
||||
HooksProvider
|
||||
nosurfx.CSRFTokenGeneratorProvider
|
||||
logrusx.Provider
|
||||
httpx.WriterProvider
|
||||
}
|
||||
|
||||
HookExecutor struct {
|
||||
d executorDependencies
|
||||
}
|
||||
|
||||
HookExecutorProvider interface {
|
||||
VerificationExecutor() *HookExecutor
|
||||
}
|
||||
)
|
||||
|
||||
func NewHookExecutor(d executorDependencies) *HookExecutor {
|
||||
return &HookExecutor{
|
||||
d: d,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HookExecutor) PreVerificationHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
|
||||
hooks, err := e.d.PreVerificationHooks(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, executor := range hooks {
|
||||
if err := executor.ExecuteVerificationPreHook(w, r, a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *HookExecutor) PostVerificationHook(w http.ResponseWriter, r *http.Request, a *Flow, i *identity.Identity) error {
|
||||
e.d.Logger().
|
||||
WithRequest(r).
|
||||
WithField("identity_id", i.ID).
|
||||
Debug("Running ExecutePostVerificationHooks.")
|
||||
hooks, err := e.d.PostVerificationHooks(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, executor := range hooks {
|
||||
if err := executor.ExecutePostVerificationHook(w, r, a, i); err != nil {
|
||||
return flow.HandleHookError(w, r, a, i.Traits, node.LinkGroup, err, e.d, e.d)
|
||||
}
|
||||
|
||||
e.d.Logger().WithRequest(r).
|
||||
WithField("executor", fmt.Sprintf("%T", executor)).
|
||||
WithField("executor_position", k).
|
||||
WithField("executors", PostHookVerificationExecutorNames(hooks)).
|
||||
WithField("identity_id", i.ID).
|
||||
Debug("ExecutePostVerificationHook completed successfully.")
|
||||
}
|
||||
|
||||
trace.SpanFromContext(r.Context()).AddEvent(events.NewVerificationSucceeded(r.Context(), a.ID, i.ID, string(a.Type), a.Active.String()))
|
||||
|
||||
e.d.Logger().
|
||||
WithRequest(r).
|
||||
WithField("identity_id", i.ID).
|
||||
Debug("Post verification execution hooks completed successfully.")
|
||||
|
||||
return nil
|
||||
}
|
||||
111
selfservice/flow/verification/hook_test.go
Normal file
111
selfservice/flow/verification/hook_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package verification_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
|
||||
"github.com/ory/kratos/selfservice/flow/verification"
|
||||
|
||||
"github.com/gobuffalo/httptest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"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/x"
|
||||
)
|
||||
|
||||
func TestVerificationExecutor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
|
||||
newServer := func(t *testing.T, i *identity.Identity, ft flow.Type) *httptest.Server {
|
||||
router := http.NewServeMux()
|
||||
router.HandleFunc("GET /verification/pre", func(w http.ResponseWriter, r *http.Request) {
|
||||
strategies, _, err := reg.GetActiveVerificationStrategies(r.Context())
|
||||
require.NoError(t, err)
|
||||
a, err := verification.NewFlow(reg, time.Minute, nosurfx.FakeCSRFToken, r, strategies, ft)
|
||||
require.NoError(t, err)
|
||||
if testhelpers.SelfServiceHookErrorHandler(t, w, r, verification.ErrHookAbortFlow, reg.VerificationExecutor().PreVerificationHook(w, r, a)) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
})
|
||||
|
||||
router.HandleFunc("GET /verification/post", func(w http.ResponseWriter, r *http.Request) {
|
||||
strategies, _, err := reg.GetActiveVerificationStrategies(r.Context())
|
||||
require.NoError(t, err)
|
||||
a, err := verification.NewFlow(reg, time.Minute, nosurfx.FakeCSRFToken, r, strategies, ft)
|
||||
require.NoError(t, err)
|
||||
a.RequestURL = x.RequestURL(r).String()
|
||||
if testhelpers.SelfServiceHookErrorHandler(t, w, r, verification.ErrHookAbortFlow, reg.VerificationExecutor().PostVerificationHook(w, r, a, i)) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(router)
|
||||
t.Cleanup(ts.Close)
|
||||
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, ts.URL)
|
||||
return ts
|
||||
}
|
||||
|
||||
t.Run("method=PostVerificationHook", 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, "/verification/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.ViperKeySelfServiceVerificationAfter, config.HookGlobal),
|
||||
[]config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}})
|
||||
i := testhelpers.SelfServiceHookFakeIdentity(t)
|
||||
ts := newServer(t, i, flow.TypeBrowser)
|
||||
|
||||
res, _ := testhelpers.SelfServiceMakeHookRequest(t, ts, "/verification/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.ViperKeySelfServiceVerificationAfter, config.HookGlobal),
|
||||
[]config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecutePostVerificationHook": "abort"}`)}})
|
||||
i := testhelpers.SelfServiceHookFakeIdentity(t)
|
||||
ts := newServer(t, i, flow.TypeBrowser)
|
||||
|
||||
res, body := testhelpers.SelfServiceMakeHookRequest(t, ts, "/verification/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=PreVerificationHook", testhelpers.TestSelfServicePreHook(
|
||||
config.ViperKeySelfServiceVerificationBeforeHooks,
|
||||
testhelpers.SelfServiceMakeVerificationPreHookRequest,
|
||||
func(t *testing.T) *httptest.Server {
|
||||
i := testhelpers.SelfServiceHookFakeIdentity(t)
|
||||
return newServer(t, i, kind)
|
||||
},
|
||||
conf,
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
23
selfservice/flow/verification/persistence.go
Normal file
23
selfservice/flow/verification/persistence.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package verification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
type (
|
||||
FlowPersistenceProvider interface {
|
||||
VerificationFlowPersister() FlowPersister
|
||||
}
|
||||
FlowPersister interface {
|
||||
CreateVerificationFlow(context.Context, *Flow) error
|
||||
GetVerificationFlow(ctx context.Context, id uuid.UUID) (*Flow, error)
|
||||
UpdateVerificationFlow(context.Context, *Flow) error
|
||||
DeleteExpiredVerificationFlows(context.Context, time.Time, int) error
|
||||
}
|
||||
)
|
||||
17
selfservice/flow/verification/state.go
Normal file
17
selfservice/flow/verification/state.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package verification
|
||||
|
||||
import "github.com/ory/kratos/selfservice/flow"
|
||||
|
||||
// Verification Flow State
|
||||
//
|
||||
// The state represents the state of the verification 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 verificationFlowState
|
||||
type State = flow.State
|
||||
70
selfservice/flow/verification/strategy.go
Normal file
70
selfservice/flow/verification/strategy.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package verification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
//swagger:enum VerificationStrategy
|
||||
type VerificationStrategy string
|
||||
|
||||
const (
|
||||
VerificationStrategyLink VerificationStrategy = "link"
|
||||
VerificationStrategyCode VerificationStrategy = "code"
|
||||
)
|
||||
|
||||
type (
|
||||
Strategy interface {
|
||||
VerificationStrategyID() string
|
||||
NodeGroup() node.UiNodeGroup
|
||||
PopulateVerificationMethod(*http.Request, *Flow) error
|
||||
Verify(w http.ResponseWriter, r *http.Request, f *Flow) (err error)
|
||||
}
|
||||
PrimaryStrategy interface {
|
||||
Strategy
|
||||
SendVerificationCode(context.Context, *Flow, *identity.Identity, identity.VerifiableAddressLike) error
|
||||
}
|
||||
Strategies []Strategy
|
||||
StrategyProvider interface {
|
||||
VerificationStrategies(ctx context.Context) Strategies
|
||||
AllVerificationStrategies() Strategies
|
||||
GetActiveVerificationStrategies(context.Context) (active Strategies, primary PrimaryStrategy, err error)
|
||||
}
|
||||
)
|
||||
|
||||
func (s Strategies) ActiveStrategies(id string) (active Strategies, primary PrimaryStrategy, err error) {
|
||||
ids := make([]string, len(s))
|
||||
activeStrategies := Strategies{}
|
||||
for k, ss := range s {
|
||||
ids[k] = ss.VerificationStrategyID()
|
||||
if ps, isPrimary := ss.(PrimaryStrategy); ss.VerificationStrategyID() == id || !isPrimary {
|
||||
activeStrategies = append(activeStrategies, ss)
|
||||
if isPrimary {
|
||||
primary = ps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if primary == nil {
|
||||
return nil, nil, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("unable to find strategy for %s have %v", id, ids))
|
||||
}
|
||||
|
||||
return activeStrategies, primary, nil
|
||||
}
|
||||
|
||||
func (s Strategies) MustStrategy(id string) Strategy {
|
||||
_, strategy, err := s.ActiveStrategies(id)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return strategy
|
||||
}
|
||||
31
selfservice/flow/verification/stub/extension/schema.json
Normal file
31
selfservice/flow/verification/stub/extension/schema.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"emails": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"verification": {
|
||||
"via": "email"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"verification": {
|
||||
"via": "email"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
selfservice/flow/verification/stub/identity.schema.json
Normal file
16
selfservice/flow/verification/stub/identity.schema.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bar": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
165
selfservice/flow/verification/test/persistence.go
Normal file
165
selfservice/flow/verification/test/persistence.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// 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/verification"
|
||||
"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 *verification.Flow) {
|
||||
r.ID = uuid.UUID{}
|
||||
}
|
||||
|
||||
return func(t *testing.T) {
|
||||
nid, p := testhelpers.NewNetworkUnlessExisting(t, ctx, p)
|
||||
|
||||
t.Run("case=should error when the verification request does not exist", func(t *testing.T) {
|
||||
_, err := p.GetVerificationFlow(ctx, x.NewUUID())
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
newFlow := func(t *testing.T) *verification.Flow {
|
||||
var r verification.Flow
|
||||
require.NoError(t, faker.FakeData(&r))
|
||||
clearids(&r)
|
||||
r.State = flow.StateChooseMethod
|
||||
return &r
|
||||
}
|
||||
|
||||
t.Run("case=should create a new verification flow", func(t *testing.T) {
|
||||
r := newFlow(t)
|
||||
err := p.CreateVerificationFlow(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.GetVerificationFlow(ctx, r.ID)
|
||||
require.ErrorIs(t, err, sqlcon.ErrNoRows())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should create with set ids", func(t *testing.T) {
|
||||
var r verification.Flow
|
||||
require.NoError(t, faker.FakeData(&r))
|
||||
r.State = flow.StateChooseMethod
|
||||
require.NoError(t, p.CreateVerificationFlow(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.GetVerificationFlow(ctx, r.ID)
|
||||
require.ErrorIs(t, err, sqlcon.ErrNoRows())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should create and fetch a verification request", func(t *testing.T) {
|
||||
expected := newFlow(t)
|
||||
require.NoError(t, p.CreateVerificationFlow(ctx, expected))
|
||||
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.GetVerificationFlow(ctx, expected.ID)
|
||||
require.ErrorIs(t, err, sqlcon.ErrNoRows())
|
||||
})
|
||||
|
||||
actual, err := p.GetVerificationFlow(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 verification 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.CreateVerificationFlow(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.UpdateVerificationFlow(ctx, expected), sqlcon.ErrNoRows())
|
||||
|
||||
actual, err := p.GetVerificationFlow(ctx, expected.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, "/new-request-url", actual.RequestURL)
|
||||
})
|
||||
|
||||
require.NoError(t, p.UpdateVerificationFlow(ctx, expected))
|
||||
|
||||
actual, err := p.GetVerificationFlow(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.CreateVerificationFlow(ctx, expected)
|
||||
require.NoError(t, err)
|
||||
|
||||
actual, err := p.GetVerificationFlow(ctx, expected.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, p.UpdateVerificationFlow(ctx, actual))
|
||||
|
||||
actual, err = p.GetVerificationFlow(ctx, expected.ID)
|
||||
require.NoError(t, err)
|
||||
assertx.EqualAsJSON(t, expected.UI, actual.UI)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user