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

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

View File

@@ -0,0 +1,18 @@
{
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/password/settings.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": [
"email"
],
"properties": {
"csrf_token": {
"type": "string"
},
"email": {
"type": "string",
"format": "email",
"minLength": 1
}
}
}

View File

@@ -0,0 +1,29 @@
{
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/profile/settings.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"method": {
"type": "string"
},
"token": {
"type": "string"
},
"email": {
"type": "string",
"format": "email_loose",
"maxLength": 320
},
"flow": {
"type": "string",
"format": "uuid"
},
"csrf_token": {
"type": "string"
},
"transient_payload": {
"type": "object",
"additionalProperties": true
}
}
}

View File

@@ -0,0 +1,29 @@
{
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/profile/settings.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"method": {
"type": "string"
},
"token": {
"type": "string"
},
"email": {
"type": "string",
"format": "email_loose",
"maxLength": 320
},
"flow": {
"type": "string",
"format": "uuid"
},
"csrf_token": {
"type": "string"
},
"transient_payload": {
"type": "object",
"additionalProperties": true
}
}
}

View File

@@ -0,0 +1,53 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "email",
"node_type": "input",
"required": true,
"type": "email"
},
"group": "link",
"messages": [],
"meta": {
"label": {
"id": 1070007,
"text": "Email",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "method",
"node_type": "input",
"type": "submit",
"value": "link"
},
"group": "link",
"messages": [],
"meta": {
"label": {
"id": 1070009,
"text": "Continue",
"type": "info"
}
},
"type": "input"
}
]

View File

@@ -0,0 +1,54 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "link",
"attributes": {
"name": "email",
"type": "email",
"value": "test@ory.sh",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070007,
"text": "Email",
"type": "info"
}
}
},
{
"type": "input",
"group": "link",
"attributes": {
"name": "method",
"type": "submit",
"value": "link",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070009,
"text": "Continue",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,53 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "email",
"node_type": "input",
"required": true,
"type": "email"
},
"group": "link",
"messages": [],
"meta": {
"label": {
"id": 1070007,
"text": "Email",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "method",
"node_type": "input",
"type": "submit",
"value": "link"
},
"group": "link",
"messages": [],
"meta": {
"label": {
"id": 1070009,
"text": "Continue",
"type": "info"
}
},
"type": "input"
}
]

View File

@@ -0,0 +1,54 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "link",
"attributes": {
"name": "email",
"type": "email",
"value": "test@ory.sh",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070007,
"text": "Email",
"type": "info"
}
}
},
{
"type": "input",
"group": "link",
"attributes": {
"name": "method",
"type": "submit",
"value": "link",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070009,
"text": "Continue",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,32 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link
import (
"context"
"github.com/gofrs/uuid"
)
type (
RecoveryTokenPersister interface {
CreateRecoveryToken(ctx context.Context, token *RecoveryToken) error
UseRecoveryToken(ctx context.Context, fID uuid.UUID, token string) (*RecoveryToken, error)
DeleteRecoveryToken(ctx context.Context, token string) error
}
RecoveryTokenPersistenceProvider interface {
RecoveryTokenPersister() RecoveryTokenPersister
}
VerificationTokenPersister interface {
CreateVerificationToken(ctx context.Context, token *VerificationToken) error
UseVerificationToken(ctx context.Context, fID uuid.UUID, token string) (*VerificationToken, error)
DeleteVerificationToken(ctx context.Context, token string) error
}
VerificationTokenPersistenceProvider interface {
VerificationTokenPersister() VerificationTokenPersister
}
)

View File

@@ -0,0 +1,14 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link
import (
_ "embed"
)
//go:embed .schema/recovery.schema.json
var recoveryMethodSchema []byte
//go:embed .schema/verification.schema.json
var verificationMethodSchema []byte

View File

@@ -0,0 +1,279 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link
import (
"context"
"net/url"
"github.com/pkg/errors"
"github.com/ory/kratos/courier"
"github.com/ory/kratos/courier/template"
"github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/selfservice/flow/verification"
"github.com/ory/kratos/x"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/sqlcon"
"github.com/ory/x/urlx"
)
type (
senderDependencies interface {
courier.Provider
courier.ConfigProvider
identity.PoolProvider
identity.ManagementProvider
identity.PrivilegedPoolProvider
logrusx.Provider
config.Provider
VerificationTokenPersistenceProvider
RecoveryTokenPersistenceProvider
hydra.Provider
httpx.ClientProvider
}
SenderProvider interface {
LinkSender() *Sender
}
Sender struct {
r senderDependencies
}
)
var ErrUnknownAddress = errors.New("verification requested for unknown address")
func NewSender(r senderDependencies) *Sender {
return &Sender{r: r}
}
// SendRecoveryLink sends a recovery link to the specified address
//
// If the address does not exist in the store and dispatching invalid emails is enabled (CourierEnableInvalidDispatch is
// true), an email is still being sent to prevent account enumeration attacks. In that case, this function returns the
// ErrUnknownAddress error.
func (s *Sender) SendRecoveryLink(ctx context.Context, f *recovery.Flow, via, to string) error {
s.r.Logger().
WithField("via", via).
WithSensitiveField("address", to).
Debug("Preparing recovery link.")
address, err := s.r.IdentityPool().FindRecoveryAddressByValue(ctx, via, to)
if errors.Is(err, sqlcon.ErrNoRows()) {
notifyUnknownRecipients := s.r.Config().SelfServiceFlowRecoveryNotifyUnknownRecipients(ctx)
s.r.Logger().
WithField("via", via).
WithField("strategy", "link").
WithSensitiveField("email_address", address).
WithField("was_notified", notifyUnknownRecipients).
Info("Account recovery was requested for an unknown address.")
transientPayload, err := x.ParseRawMessageOrEmpty(f.GetTransientPayload())
if err != nil {
return errors.WithStack(err)
}
if !notifyUnknownRecipients {
// do nothing
} else if err := s.send(ctx, string(via), email.NewRecoveryInvalid(s.r, &email.RecoveryInvalidModel{
To: to,
RequestURL: f.GetRequestURL(),
TransientPayload: transientPayload,
})); err != nil {
return err
}
return errors.WithStack(ErrUnknownAddress)
} else if err != nil {
// DB error
return err
}
// Get the identity associated with the recovery address
i, err := s.r.IdentityPool().GetIdentity(ctx, address.IdentityID, identity.ExpandDefault)
if err != nil {
return err
}
token := NewSelfServiceRecoveryToken(address, f, s.r.Config().SelfServiceLinkMethodLifespan(ctx))
if err := s.r.RecoveryTokenPersister().CreateRecoveryToken(ctx, token); err != nil {
return err
}
if err := s.SendRecoveryTokenTo(ctx, f, i, address, token); err != nil {
return err
}
return nil
}
// SendVerificationLink sends a verification link to the specified address
//
// If the address does not exist in the store and dispatching invalid emails is enabled (CourierEnableInvalidDispatch is
// true), an email is still being sent to prevent account enumeration attacks. In that case, this function returns the
// ErrUnknownAddress error.
func (s *Sender) SendVerificationLink(ctx context.Context, f *verification.Flow, via, to string) error {
s.r.Logger().
WithField("via", via).
WithSensitiveField("address", to).
Debug("Preparing verification link.")
address, err := s.r.IdentityPool().FindVerifiableAddressByValue(ctx, via, to)
if errors.Is(err, sqlcon.ErrNoRows()) {
notifyUnknownRecipients := s.r.Config().SelfServiceFlowVerificationNotifyUnknownRecipients(ctx)
s.r.Logger().
WithField("via", via).
WithField("strategy", "link").
WithSensitiveField("email_address", to).
WithField("was_notified", notifyUnknownRecipients).
Info("Address verification was requested for an unknown address.")
transientPayload, err := x.ParseRawMessageOrEmpty(f.GetTransientPayload())
if err != nil {
return errors.WithStack(err)
}
if !notifyUnknownRecipients {
// do nothing
} else if err := s.send(ctx, string(via), email.NewVerificationInvalid(s.r, &email.VerificationInvalidModel{
To: to,
RequestURL: f.GetRequestURL(),
TransientPayload: transientPayload,
})); err != nil {
return err
}
return errors.WithStack(ErrUnknownAddress)
} else if err != nil {
// DB error
return err
}
// Get the identity associated with the verification address
i, err := s.r.IdentityPool().GetIdentity(ctx, address.IdentityID, identity.ExpandDefault)
if err != nil {
return err
}
token := NewSelfServiceVerificationToken(address, f, s.r.Config().SelfServiceLinkMethodLifespan(ctx))
if err := s.r.VerificationTokenPersister().CreateVerificationToken(ctx, token); err != nil {
return err
}
if err := s.SendVerificationTokenTo(ctx, f, i, address, token); err != nil {
return err
}
address.Status = identity.VerifiableAddressStatusSent
return s.r.PrivilegedIdentityPool().UpdateVerifiableAddress(ctx, address, "status")
}
func (s *Sender) SendRecoveryTokenTo(ctx context.Context, f *recovery.Flow, i *identity.Identity, address *identity.RecoveryAddress, token *RecoveryToken) error {
s.r.Logger().
WithField("via", address.Via).
WithField("identity_id", address.IdentityID).
WithField("recovery_link_id", token.ID).
WithSensitiveField("email_address", address.Value).
WithSensitiveField("recovery_link_token", token.Token).
Info("Sending out recovery email with recovery link.")
model, err := x.StructToMap(i)
if err != nil {
return err
}
transientPayload, err := x.ParseRawMessageOrEmpty(f.GetTransientPayload())
if err != nil {
return errors.WithStack(err)
}
recoveryUrl := urlx.CopyWithQuery(
urlx.AppendPaths(x.CourierBaseURL(f.GetCourierBaseURL(), s.r.Config().SelfPublicURL(ctx)), recovery.RouteSubmitFlow),
url.Values{
"token": {token.Token},
"flow": {f.ID.String()},
}).
String()
return s.send(ctx, string(address.Via), email.NewRecoveryValid(s.r,
&email.RecoveryValidModel{
To: address.Value,
RecoveryURL: recoveryUrl,
Identity: model,
RequestURL: f.GetRequestURL(),
TransientPayload: transientPayload,
ExpiresInMinutes: int(s.r.Config().SelfServiceLinkMethodLifespan(ctx).Minutes()),
}))
}
func (s *Sender) SendVerificationTokenTo(ctx context.Context, f *verification.Flow, i *identity.Identity, sendable identity.VerifiableAddressLike, token *VerificationToken) error {
address, via := sendable.Address(), sendable.DeliveryVia()
s.r.Logger().
WithField("via", via).
WithField("identity_id", i.ID).
WithField("verification_link_id", token.ID).
WithSensitiveField("email_address", address).
WithSensitiveField("verification_link_token", token.Token).
Info("Sending out verification email with verification link.")
model, err := x.StructToMap(i)
if err != nil {
return err
}
transientPayload, err := x.ParseRawMessageOrEmpty(f.GetTransientPayload())
if err != nil {
return errors.WithStack(err)
}
// If the flow was initiated by an OAuth2 login challenge, expose a scoped
// view of the OAuth2 client to the courier templates so that messages can
// be branded per client.
if f.OAuth2LoginChallenge != "" && f.HydraLoginRequest == nil {
hlr, err := s.r.Hydra().GetLoginRequest(ctx, string(f.OAuth2LoginChallenge))
if err != nil {
return errors.WithStack(err)
}
f.HydraLoginRequest = hlr
}
verificationUrl := urlx.CopyWithQuery(
urlx.AppendPaths(x.CourierBaseURL(f.GetCourierBaseURL(), s.r.Config().SelfPublicURL(ctx)), verification.RouteSubmitFlow),
url.Values{
"flow": {f.ID.String()},
"token": {token.Token},
}).String()
if err := s.send(ctx, via, email.NewVerificationValid(s.r,
&email.VerificationValidModel{
To: address,
VerificationURL: verificationUrl,
Identity: model,
RequestURL: f.GetRequestURL(),
TransientPayload: transientPayload,
ExpiresInMinutes: int(s.r.Config().SelfServiceLinkMethodLifespan(ctx).Minutes()),
OAuth2LoginRequest: template.NewOAuth2LoginRequest(f.HydraLoginRequest),
})); err != nil {
return err
}
return nil
}
func (s *Sender) send(ctx context.Context, via string, t courier.EmailTemplate) error {
switch via {
case identity.AddressTypeEmail:
c, err := s.r.Courier(ctx)
if err != nil {
return err
}
_, err = c.QueueEmail(ctx, t)
return err
default:
return errors.Errorf("received unexpected via type: %s", via)
}
}

View File

@@ -0,0 +1,253 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/courier"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hydra"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/selfservice/flow/verification"
"github.com/ory/kratos/selfservice/strategy/link"
"github.com/ory/kratos/x"
"github.com/ory/x/contextx"
"github.com/ory/x/sqlxx"
"github.com/ory/x/urlx"
)
func TestManager(t *testing.T) {
t.Parallel()
ctx := t.Context()
conf, reg := pkg.NewFastRegistryWithMocks(t)
initViper(t, conf)
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/default.schema.json")
ctx = contextx.WithConfigValues(ctx, map[string]any{
config.ViperKeyPublicBaseURL: "https://www.ory.com/",
config.ViperKeyCourierSMTPURL: "smtp://foo@bar@dev.null/",
config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients: true,
config.ViperKeySelfServiceVerificationNotifyUnknownRecipients: true,
})
u := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.com/")}
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.Traits = identity.Traits(`{"email": "tracked@ory.sh"}`)
require.NoError(t, reg.IdentityManager().Create(ctx, i))
for _, tc := range []struct {
d string
setupContext func(ctx context.Context) context.Context
recoveryURL string
verificationURL string
}{{
d: "without BaseURL",
setupContext: func(ctx context.Context) context.Context { return ctx },
recoveryURL: "https://www.ory.com/self-service/recovery?flow=",
verificationURL: "https://www.ory.com/self-service/verification?flow=",
}, {
d: "with BaseURL",
setupContext: func(ctx context.Context) context.Context {
return x.WithBaseURL(ctx, urlx.ParseOrPanic("https://proxy.example.com/some/subpath/"))
},
recoveryURL: "https://proxy.example.com/some/subpath/self-service/recovery?flow=",
verificationURL: "https://proxy.example.com/some/subpath/self-service/verification?flow=",
}} {
ctx := tc.setupContext(ctx)
t.Run("case="+tc.d, func(t *testing.T) {
t.Run("method=SendRecoveryLink", func(t *testing.T) {
s, _, err := reg.RecoveryStrategies(ctx).ActiveStrategies("link")
require.NoError(t, err)
f, err := recovery.NewFlow(reg, time.Hour, "", u.WithContext(ctx), s, flow.TypeBrowser)
require.NoError(t, err)
require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(ctx, f))
require.NoError(t, reg.LinkSender().SendRecoveryLink(ctx, f, "email", "tracked@ory.sh"))
require.EqualError(t, reg.LinkSender().SendRecoveryLink(ctx, f, "email", "not-tracked@ory.sh"), link.ErrUnknownAddress.Error())
messages, err := reg.CourierPersister().NextMessages(ctx, 12)
require.NoError(t, err)
require.Len(t, messages, 2)
assert.EqualValues(t, "tracked@ory.sh", messages[0].Recipient)
assert.Contains(t, messages[0].Subject, "Recover access to your account")
assert.Contains(t, messages[0].Body, tc.recoveryURL)
assert.Contains(t, messages[0].Body, "token=")
assert.Contains(t, messages[0].Body, "flow=")
assert.EqualValues(t, "not-tracked@ory.sh", messages[1].Recipient)
assert.Contains(t, messages[1].Subject, "Account access attempted")
assert.NotContains(t, messages[1].Body, f.RequestURL+"self-service/recovery?flow=")
assert.NotContains(t, messages[1].Body, "token=")
assert.NotContains(t, messages[1].Body, "flow=")
})
t.Run("method=SendRecoveryLink via HTTP", func(t *testing.T) {
type requestBody struct {
Recipient string
RecoveryURL string `json:"recovery_url"`
To string
TemplateType string
Subject string
}
messages := make(chan *requestBody, 2)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var message requestBody
require.NoError(t, json.NewDecoder(r.Body).Decode(&message))
messages <- &message
}))
t.Cleanup(srv.Close)
ctx = contextx.WithConfigValues(ctx, map[string]any{
config.ViperKeyCourierDeliveryStrategy: "http",
config.ViperKeyCourierHTTPRequestConfig + ".url": srv.URL,
})
s, _, err := reg.RecoveryStrategies(ctx).ActiveStrategies("link")
require.NoError(t, err)
f, err := recovery.NewFlow(reg, time.Hour, "", u.WithContext(ctx), s, flow.TypeBrowser)
require.NoError(t, err)
require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(ctx, f))
require.NoError(t, reg.LinkSender().SendRecoveryLink(ctx, f, "email", "tracked@ory.sh"))
require.EqualError(t, reg.LinkSender().SendRecoveryLink(ctx, f, "email", "not-tracked@ory.sh"), link.ErrUnknownAddress.Error())
cour, err := reg.Courier(ctx)
require.NoError(t, err)
require.NoError(t, cour.DispatchQueue(ctx))
close(messages)
require.Len(t, messages, 2)
msg := <-messages
assert.EqualValues(t, "tracked@ory.sh", msg.To)
assert.Contains(t, msg.Subject, "Recover access to your account")
assert.Contains(t, msg.RecoveryURL, tc.recoveryURL)
msg = <-messages
assert.EqualValues(t, "not-tracked@ory.sh", msg.To)
assert.Contains(t, msg.Subject, "Account access attempted")
assert.NotContains(t, msg.RecoveryURL, tc.recoveryURL)
})
t.Run("method=SendVerificationLink", func(t *testing.T) {
strategies, _, err := reg.GetActiveVerificationStrategies(ctx)
require.NoError(t, err)
f, err := verification.NewFlow(reg, time.Hour, "", u.WithContext(ctx), strategies, flow.TypeBrowser)
require.NoError(t, err)
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f))
require.NoError(t, reg.LinkSender().SendVerificationLink(ctx, f, "email", "tracked@ory.sh"))
require.EqualError(t, reg.LinkSender().SendVerificationLink(ctx, f, "email", "not-tracked@ory.sh"), link.ErrUnknownAddress.Error())
messages, err := reg.CourierPersister().NextMessages(ctx, 12)
require.NoError(t, err)
require.Len(t, messages, 2)
assert.EqualValues(t, "tracked@ory.sh", messages[0].Recipient)
assert.Contains(t, messages[0].Subject, "Please verify")
assert.Contains(t, messages[0].Body, tc.verificationURL)
assert.Contains(t, messages[0].Body, "token=")
assert.Contains(t, messages[0].Body, "flow=")
assert.EqualValues(t, "not-tracked@ory.sh", messages[1].Recipient)
assert.Contains(t, messages[1].Subject, "tried to verify")
assert.NotContains(t, messages[1].Body, tc.verificationURL)
address, err := reg.IdentityPool().FindVerifiableAddressByValue(ctx, identity.AddressTypeEmail, "tracked@ory.sh")
require.NoError(t, err)
assert.EqualValues(t, identity.VerifiableAddressStatusSent, address.Status)
})
})
}
t.Run("method=SendVerificationLink with OAuth2 login challenge", func(t *testing.T) {
reg.SetHydra(hydra.NewFake())
strategies, _, err := reg.GetActiveVerificationStrategies(ctx)
require.NoError(t, err)
f, err := verification.NewFlow(reg, time.Hour, "", u.WithContext(ctx), strategies, flow.TypeBrowser)
require.NoError(t, err)
f.OAuth2LoginChallenge = sqlxx.NullString(hydra.FakeValidLoginChallenge)
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f))
require.NoError(t, reg.LinkSender().SendVerificationLink(ctx, f, "email", "tracked@ory.sh"))
messages, err := reg.CourierPersister().NextMessages(ctx, 12)
require.NoError(t, err)
require.Len(t, messages, 1)
templateData := messages[0].TemplateData
assert.Equal(t, hydra.FakeValidLoginChallenge, gjson.GetBytes(templateData, "oauth2_login_request.challenge").String())
assert.Equal(t, hydra.FakeClientID, gjson.GetBytes(templateData, "oauth2_login_request.client.client_id").String())
})
t.Run("case=should be able to disable invalid email dispatch", func(t *testing.T) {
for _, tc := range []struct {
flow string
send func(t *testing.T)
configKey string
}{
{
flow: "recovery",
configKey: config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients,
send: func(t *testing.T) {
s, _, err := reg.RecoveryStrategies(ctx).ActiveStrategies("link")
require.NoError(t, err)
f, err := recovery.NewFlow(reg, time.Hour, "", u.WithContext(ctx), s, flow.TypeBrowser)
require.NoError(t, err)
require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(ctx, f))
err = reg.LinkSender().SendRecoveryLink(ctx, f, "email", "not-tracked@ory.sh")
require.ErrorIs(t, err, link.ErrUnknownAddress)
},
},
{
flow: "verification",
configKey: config.ViperKeySelfServiceVerificationNotifyUnknownRecipients,
send: func(t *testing.T) {
s, _, err := reg.VerificationStrategies(ctx).ActiveStrategies("link")
require.NoError(t, err)
f, err := verification.NewFlow(reg, time.Hour, "", u.WithContext(ctx), s, flow.TypeBrowser)
require.NoError(t, err)
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f))
err = reg.LinkSender().SendVerificationLink(ctx, f, "email", "not-tracked@ory.sh")
require.ErrorIs(t, err, link.ErrUnknownAddress)
},
},
} {
t.Run("strategy="+tc.flow, func(t *testing.T) {
ctx = contextx.WithConfigValue(ctx, tc.configKey, false)
tc.send(t)
messages, err := reg.CourierPersister().NextMessages(ctx, 0)
require.ErrorIs(t, err, courier.ErrQueueEmpty)
require.Len(t, messages, 0)
})
}
})
}

View File

@@ -0,0 +1,89 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link
import (
"github.com/ory/kratos/courier"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/errorx"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/selfservice/flow/verification"
"github.com/ory/kratos/session"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/x/transaction"
"github.com/ory/x/clock"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
)
var (
_ recovery.Strategy = (*Strategy)(nil)
_ verification.Strategy = (*Strategy)(nil)
_ x.Handler = (*Strategy)(nil)
)
type (
// FlowMethod contains the configuration for this selfservice strategy.
FlowMethod struct {
*container.Container
}
dependencies interface {
clock.Provider
nosurfx.CSRFProvider
nosurfx.CSRFTokenGeneratorProvider
httpx.WriterProvider
logrusx.Provider
otelx.Provider
transaction.PersistenceProvider
config.Provider
session.HandlerProvider
session.ManagementProvider
session.PersistenceProvider
settings.HandlerProvider
settings.FlowPersistenceProvider
settings.HookExecutorProvider
identity.ValidationProvider
identity.ManagementProvider
identity.PoolProvider
identity.PrivilegedPoolProvider
identity.PendingTraitsChangePersistenceProvider
courier.Provider
errorx.ManagementProvider
recovery.ErrorHandlerProvider
recovery.FlowPersistenceProvider
recovery.StrategyProvider
recovery.HookExecutorProvider
verification.ErrorHandlerProvider
verification.FlowPersistenceProvider
verification.StrategyProvider
verification.HookExecutorProvider
verification.HandlerProvider
RecoveryTokenPersistenceProvider
VerificationTokenPersistenceProvider
SenderProvider
schema.IdentitySchemaProvider
}
Strategy struct{ d dependencies }
)
func NewStrategy(d dependencies) *Strategy { return &Strategy{d: d} }
func (s *Strategy) NodeGroup() node.UiNodeGroup { return node.LinkGroup }

View File

@@ -0,0 +1,68 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/ory/x/decoderx"
)
// decodeSubmission drives the production decode path used by the link
// strategy's decodeRecovery / decodeVerification.
func decodeSubmission(t *testing.T, schema []byte, email string) error {
t.Helper()
compiler, err := decoderx.HTTPRawJSONSchemaCompiler(schema)
require.NoError(t, err)
form := url.Values{"email": {email}, "method": {"link"}}
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
var body struct {
Email string `json:"email"`
}
return decoderx.Decode(req, &body, compiler,
decoderx.HTTPDecoderUseQueryAndBody(),
decoderx.HTTPKeepRequestBody(true),
decoderx.HTTPDecoderAllowedMethods("POST"),
decoderx.HTTPDecoderSetValidatePayloads(true),
decoderx.HTTPDecoderJSONFollowsFormFormat(),
)
}
// TestLinkRecoverySubmissionAcceptsNonRFCEmail and
// TestLinkVerificationSubmissionAcceptsNonRFCEmail verify the `email_loose`
// submission schemas accept schema-approved non-RFC carrier addresses while
// still rejecting injection-unsafe input at decode.
func TestLinkRecoverySubmissionAcceptsNonRFCEmail(t *testing.T) {
t.Parallel()
require.NoError(t, decodeSubmission(t, recoveryMethodSchema, "user@example.org"))
require.NoError(t, decodeSubmission(t, recoveryMethodSchema, "foo.@docomo.ne.jp"))
for _, evil := range []string{"foo@ory.sh\r\nBcc: evil@x", "foo<bar>@ory.sh", "foo@ory.sh,evil@x"} {
t.Run("rejected="+evil, func(t *testing.T) {
require.Error(t, decodeSubmission(t, recoveryMethodSchema, evil))
})
}
}
func TestLinkVerificationSubmissionAcceptsNonRFCEmail(t *testing.T) {
t.Parallel()
require.NoError(t, decodeSubmission(t, verificationMethodSchema, "user@example.org"))
require.NoError(t, decodeSubmission(t, verificationMethodSchema, "foo.@docomo.ne.jp"))
for _, evil := range []string{"foo@ory.sh\r\nBcc: evil@x", "foo<bar>@ory.sh", "foo@ory.sh,evil@x"} {
t.Run("rejected="+evil, func(t *testing.T) {
require.Error(t, decodeSubmission(t, verificationMethodSchema, evil))
})
}
}

View File

@@ -0,0 +1,560 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link
import (
"context"
"encoding/json"
"net/http"
"net/url"
"time"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/ory/herodot"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/selfservice/strategy"
"github.com/ory/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/events"
"github.com/ory/kratos/x/redir"
"github.com/ory/pop/v6"
"github.com/ory/x/decoderx"
"github.com/ory/x/httprouterx"
"github.com/ory/x/otelx"
"github.com/ory/x/sqlcon"
"github.com/ory/x/sqlxx"
"github.com/ory/x/urlx"
)
const RouteAdminCreateRecoveryLink = "/recovery/link"
func (s *Strategy) RecoveryStrategyID() string {
return string(recovery.RecoveryStrategyLink)
}
func (s *Strategy) IsPrimary() bool {
return true
}
func (s *Strategy) RegisterPublicRoutes(public *httprouterx.RouterPublic) {
s.d.CSRFHandler().IgnorePath(RouteAdminCreateRecoveryLink)
public.POST(RouteAdminCreateRecoveryLink, redir.RedirectToAdminRoute(s.d))
}
func (s *Strategy) RegisterAdminRoutes(admin *httprouterx.RouterAdmin) {
wrappedCreateRecoveryLink := strategy.IsDisabled(s.d, s.RecoveryStrategyID(), s.createRecoveryLinkForIdentity)
admin.POST(RouteAdminCreateRecoveryLink, wrappedCreateRecoveryLink)
}
func (s *Strategy) PopulateRecoveryMethod(r *http.Request, f *recovery.Flow) error {
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
f.UI.GetNodes().Upsert(
// v0.5: form.Field{Name: "email", Type: "email", Required: true},
node.NewInputField("email", nil, node.LinkGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute).WithMetaLabel(text.NewInfoNodeInputEmail()),
)
f.UI.GetNodes().Append(node.NewInputField("method", s.RecoveryStrategyID(), node.LinkGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoNodeLabelContinue()))
return nil
}
// Create Recovery Link for Identity Parameters
//
// swagger:parameters createRecoveryLinkForIdentity
type _ struct {
// in: body
Body createRecoveryLinkForIdentityBody
// in: query
ReturnTo string `json:"return_to"`
}
// Create Recovery Link for Identity Request Body
//
// swagger:model createRecoveryLinkForIdentityBody
type createRecoveryLinkForIdentityBody struct {
// Identity to Recover
//
// The identity's ID you wish to recover.
//
// required: true
IdentityID uuid.UUID `json:"identity_id"`
// Link Expires In
//
// The recovery link will expire after that amount of time has passed. Defaults to the configuration value of
// `selfservice.methods.code.config.lifespan`.
//
//
// pattern: ^([0-9]+([.][0-9]+)?(ns|us|µs|ms|s|m|h))+$
// example:
// - 1h
// - 1m
// - 1s
ExpiresIn string `json:"expires_in"`
}
// Identity Recovery Link
//
// Used when an administrator creates a recovery link for an identity.
//
// swagger:model recoveryLinkForIdentity
type recoveryLinkForIdentity struct {
// Recovery Link
//
// This link can be used to recover the account.
//
// required: true
// format: uri
RecoveryLink string `json:"recovery_link"`
// Recovery Link Expires At
//
// The timestamp when the recovery link expires.
ExpiresAt time.Time `json:"expires_at"`
}
// swagger:route POST /admin/recovery/link identity createRecoveryLinkForIdentity
//
// # Create a Recovery Link
//
// This endpoint creates a recovery link which should be given to the user in order for them to recover
// (or activate) their account.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Security:
// oryAccessToken:
//
// Responses:
// 200: recoveryLinkForIdentity
// 400: errorGeneric
// 404: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-admin-low
func (s *Strategy) createRecoveryLinkForIdentity(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var p createRecoveryLinkForIdentityBody
if err := decoderx.Decode(r, &p, decoderx.HTTPJSONDecoder()); err != nil {
s.d.Writer().WriteError(w, r, err)
return
}
expiresIn := s.d.Config().SelfServiceLinkMethodLifespan(ctx)
if len(p.ExpiresIn) > 0 {
var err error
expiresIn, err = time.ParseDuration(p.ExpiresIn)
if err != nil {
s.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf(`Unable to parse "expires_in" as a duration string (e.g. "1h", "1m30s", "500ms"): %s`, p.ExpiresIn)))
return
}
}
if expiresIn <= 0 {
s.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf(`Value from "expires_in" must be result to a future time: %s`, p.ExpiresIn)))
return
}
req, err := recovery.NewFlow(s.d, expiresIn, s.d.GenerateCSRFToken(r), r, recovery.Strategies{s}, flow.TypeBrowser)
if err != nil {
s.d.Writer().WriteError(w, r, err)
return
}
id, err := s.d.IdentityPool().GetIdentity(ctx, p.IdentityID, identity.ExpandDefault)
if errors.Is(err, sqlcon.ErrNoRows()) {
s.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("The requested identity id does not exist.").WithWrap(err)))
return
} else if err != nil {
s.d.Writer().WriteError(w, r, err)
return
}
token := NewAdminRecoveryToken(id.ID, req.ID, expiresIn)
if err := s.d.TransactionalPersisterProvider().Transaction(ctx, func(ctx context.Context, c *pop.Connection) error {
if err := s.d.RecoveryFlowPersister().CreateRecoveryFlow(ctx, req); err != nil {
return err
}
return s.d.RecoveryTokenPersister().CreateRecoveryToken(ctx, token)
}); err != nil {
s.d.Writer().WriteError(w, r, err)
return
}
trace.SpanFromContext(ctx).AddEvent(
events.NewRecoveryInitiatedByAdmin(ctx, req.ID, id.ID, req.Type.String(), "link"),
)
s.d.Logger().
WithField("identity_id", id.ID).
WithSensitiveField("recovery_link_token", token).
Info("A recovery link has been created.")
s.d.Writer().Write(w, r, &recoveryLinkForIdentity{
ExpiresAt: req.ExpiresAt.UTC(),
RecoveryLink: urlx.CopyWithQuery(
urlx.AppendPaths(s.d.Config().SelfPublicURL(ctx), recovery.RouteSubmitFlow),
url.Values{
"token": {token.Token},
"flow": {req.ID.String()},
}).String(),
},
herodot.UnescapedHTML)
}
// Update Recovery Flow with Link Method
//
// swagger:model updateRecoveryFlowWithLinkMethod
type _ struct {
// Email to Recover
//
// Needs to be set when initiating the flow. If the email is a registered
// recovery email, a recovery link will be sent. If the email is not known,
// an email with details on what happened will be sent instead.
//
// format: email
// required: true
Email string `json:"email" form:"email"`
// Sending the anti-csrf token is only required for browser login flows.
CSRFToken string `form:"csrf_token" json:"csrf_token"`
// Method is the method that should be used for this recovery flow
//
// Allowed values are `link` and `code`
//
// required: true
Method recovery.RecoveryMethod `json:"method"`
// Transient data to pass along to any webhooks
//
// required: false
TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"`
}
func (s *Strategy) Recover(w http.ResponseWriter, r *http.Request, f *recovery.Flow) (err error) {
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.link.Strategy.Recover")
span.SetAttributes(attribute.String("selfservice_flows_recovery_use", s.d.Config().SelfServiceFlowRecoveryUse(ctx)))
defer otelx.End(span, &err)
body, err := s.decodeRecovery(r)
if err != nil {
return s.HandleRecoveryError(r, nil, body, err)
}
f.TransientPayload = body.TransientPayload
if len(body.Token) > 0 {
if err := flow.MethodEnabledAndAllowed(r.Context(), f.GetFlowName(), s.RecoveryStrategyID(), s.RecoveryStrategyID(), s.d); err != nil {
return s.HandleRecoveryError(r, nil, body, err)
}
return s.recoveryUseToken(ctx, w, r, f.ID, body)
}
if err := s.d.SessionManager().SessionActiveForRequest(r.Context(), r); err == nil {
if x.IsJSONRequest(r) {
session.RespondWithJSONErrorOnAuthenticated(s.d.Writer(), recovery.ErrAlreadyLoggedIn())(w, r)
} else {
session.RedirectOnAuthenticated(s.d)(w, r)
}
return errors.WithStack(flow.ErrCompletedByStrategy)
}
if err := flow.MethodEnabledAndAllowed(r.Context(), f.GetFlowName(), s.RecoveryStrategyID(), body.Method, s.d); err != nil {
return s.HandleRecoveryError(r, nil, body, err)
}
req, err := s.d.RecoveryFlowPersister().GetRecoveryFlow(r.Context(), x.ParseUUID(body.Flow))
if err != nil {
return s.HandleRecoveryError(r, req, body, err)
}
if err := req.Valid(s.d.Clock()); err != nil {
return s.HandleRecoveryError(r, req, body, err)
}
switch req.State {
case flow.StateChooseMethod,
flow.StateEmailSent:
return s.recoveryHandleFormSubmission(r, req)
case flow.StatePassedChallenge:
// was already handled, do not allow retry
return s.retryRecoveryFlowWithMessage(w, r, req.Type, text.NewErrorValidationRecoveryRetrySuccess())
default:
return s.retryRecoveryFlowWithMessage(w, r, req.Type, text.NewErrorValidationRecoveryStateFailure())
}
}
func (s *Strategy) recoveryIssueSession(ctx context.Context, w http.ResponseWriter, r *http.Request, f *recovery.Flow, id *identity.Identity) error {
f.UI.Messages.Clear()
f.State = flow.StatePassedChallenge
f.SetCSRFToken(s.d.CSRFHandler().RegenerateToken(w, r))
f.RecoveredIdentityID = uuid.NullUUID{
UUID: id.ID,
Valid: true,
}
if err := s.d.RecoveryFlowPersister().UpdateRecoveryFlow(r.Context(), f); err != nil {
return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err)
}
sess := session.NewInactiveSession()
sess.CompletedLoginFor(identity.CredentialsTypeRecoveryLink, identity.AuthenticatorAssuranceLevel1)
if err := s.d.SessionManager().ActivateSession(r, sess, id, time.Now().UTC()); err != nil {
return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err)
}
// Force load.
if err := s.d.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, sess.Identity, identity.ExpandEverything); err != nil {
return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err)
}
if err := s.d.RecoveryExecutor().PostRecoveryHook(w, r, f, sess); err != nil {
return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err)
}
if err := s.d.SessionManager().UpsertAndIssueCookie(r.Context(), w, r, sess); err != nil {
return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err)
}
sf, err := s.d.SettingsHandler().NewFlow(ctx, w, r, sess.Identity, sess, flow.TypeBrowser)
if err != nil {
return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err)
}
returnToURL := s.d.Config().SelfServiceFlowRecoveryReturnTo(r.Context(), nil)
returnTo := ""
if returnToURL != nil {
returnTo = returnToURL.String()
}
sf.RequestURL, err = redir.TakeOverReturnToParameter(f.RequestURL, sf.RequestURL, returnTo)
if err != nil {
return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err)
}
sf.UI.Messages.Set(text.NewRecoverySuccessful(s.d.Clock(), s.d.Clock().Now().Add(s.d.Config().SelfServiceFlowSettingsPrivilegedSessionMaxAge(r.Context()))))
if err := s.d.SettingsFlowPersister().UpdateSettingsFlow(r.Context(), sf); err != nil {
return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err)
}
http.Redirect(w, r, sf.AppendTo(s.d.Config().SelfServiceFlowSettingsUI(r.Context())).String(), http.StatusSeeOther)
return errors.WithStack(flow.ErrCompletedByStrategy)
}
func (s *Strategy) recoveryUseToken(ctx context.Context, w http.ResponseWriter, r *http.Request, fID uuid.UUID, body *recoverySubmitPayload) error {
token, err := s.d.RecoveryTokenPersister().UseRecoveryToken(r.Context(), fID, body.Token)
if err != nil {
if errors.Is(err, sqlcon.ErrNoRows()) {
return s.retryRecoveryFlowWithMessage(w, r, flow.TypeBrowser, text.NewErrorValidationRecoveryTokenInvalidOrAlreadyUsed())
}
return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err)
}
var f *recovery.Flow
if !token.FlowID.Valid {
f, err = recovery.NewFlow(s.d, token.ExpiresAt.Sub(s.d.Clock().Now()), s.d.GenerateCSRFToken(r), r, recovery.Strategies{s}, flow.TypeBrowser)
if err != nil {
return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err)
}
if err := s.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), f); err != nil {
return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err)
}
} else {
f, err = s.d.RecoveryFlowPersister().GetRecoveryFlow(r.Context(), token.FlowID.UUID)
if err != nil {
return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err)
}
}
if err := token.Valid(s.d.Clock()); err != nil {
return s.retryRecoveryFlowWithError(w, r, flow.TypeBrowser, err)
}
// Important to expand everything here, as we need the data for recovery.
recovered, err := s.d.IdentityPool().GetIdentity(r.Context(), token.IdentityID, identity.ExpandEverything)
if err != nil {
return s.HandleRecoveryError(r, f, nil, err)
}
// mark address as verified only for a self-service flow
if token.TokenType == RecoveryTokenTypeSelfService {
if err := s.markRecoveryAddressVerified(r, f, recovered, token.RecoveryAddress); err != nil {
return s.HandleRecoveryError(r, f, body, err)
}
}
return s.recoveryIssueSession(ctx, w, r, f, recovered)
}
func (s *Strategy) retryRecoveryFlowWithMessage(w http.ResponseWriter, r *http.Request, ft flow.Type, message *text.Message) error {
s.d.Logger().WithRequest(r).WithField("message", message).Debug("A recovery flow is being retried because a validation error occurred.")
req, err := recovery.NewFlow(s.d, s.d.Config().SelfServiceFlowRecoveryRequestLifespan(r.Context()), s.d.CSRFHandler().RegenerateToken(w, r), r, recovery.Strategies{s}, ft)
if err != nil {
return err
}
req.UI.Messages.Add(message)
if err := s.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), req); err != nil {
return err
}
if ft == flow.TypeBrowser {
http.Redirect(w, r, req.AppendTo(s.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther)
} else {
http.Redirect(w, r, urlx.CopyWithQuery(urlx.AppendPaths(s.d.Config().SelfPublicURL(r.Context()),
recovery.RouteGetFlow), url.Values{"id": {req.ID.String()}}).String(), http.StatusSeeOther)
}
return errors.WithStack(flow.ErrCompletedByStrategy)
}
func (s *Strategy) retryRecoveryFlowWithError(w http.ResponseWriter, r *http.Request, ft flow.Type, recErr error) error {
s.d.Logger().WithRequest(r).WithError(recErr).Debug("A recovery flow is being retried because a validation error occurred.")
req, err := recovery.NewFlow(s.d, s.d.Config().SelfServiceFlowRecoveryRequestLifespan(r.Context()), s.d.CSRFHandler().RegenerateToken(w, r), r, recovery.Strategies{s}, ft)
if err != nil {
return err
}
if expired := new(flow.ExpiredError); errors.As(recErr, &expired) {
return s.retryRecoveryFlowWithMessage(w, r, ft, text.NewErrorValidationRecoveryFlowExpired(s.d.Clock(), expired.ExpiredAt))
} else {
if err := req.UI.ParseError(node.LinkGroup, recErr); err != nil {
return err
}
}
if err := s.d.RecoveryFlowPersister().CreateRecoveryFlow(r.Context(), req); err != nil {
return err
}
if ft == flow.TypeBrowser {
http.Redirect(w, r, req.AppendTo(s.d.Config().SelfServiceFlowRecoveryUI(r.Context())).String(), http.StatusSeeOther)
} else {
http.Redirect(w, r, urlx.CopyWithQuery(urlx.AppendPaths(s.d.Config().SelfPublicURL(r.Context()),
recovery.RouteGetFlow), url.Values{"id": {req.ID.String()}}).String(), http.StatusSeeOther)
}
return errors.WithStack(flow.ErrCompletedByStrategy)
}
func (s *Strategy) recoveryHandleFormSubmission(r *http.Request, f *recovery.Flow) error {
body, err := s.decodeRecovery(r)
if err != nil {
return s.HandleRecoveryError(r, f, body, err)
}
if len(body.Email) == 0 {
return s.HandleRecoveryError(r, f, body, schema.NewRequiredError("#/email", "email"))
}
if err := flow.EnsureCSRF(s.d, r, f.Type, s.d.Config().DisableAPIFlowEnforcement(r.Context()), s.d.GenerateCSRFToken, body.CSRFToken); err != nil {
return s.HandleRecoveryError(r, f, body, err)
}
if err := s.d.LinkSender().SendRecoveryLink(r.Context(), f, identity.AddressTypeEmail, body.Email); err != nil {
if !errors.Is(err, ErrUnknownAddress) {
return s.HandleRecoveryError(r, f, body, err)
}
// Continue execution
}
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
f.UI.GetNodes().Upsert(
// v0.5: form.Field{Name: "email", Type: "email", Required: true, Value: body.Body.Email}
node.NewInputField("email", body.Email, node.LinkGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute).WithMetaLabel(text.NewInfoNodeInputEmail()),
)
f.Active = sqlxx.NullString(s.NodeGroup())
f.State = flow.StateEmailSent
f.UI.Messages.Set(text.NewRecoveryEmailSent())
if err := s.d.RecoveryFlowPersister().UpdateRecoveryFlow(r.Context(), f); err != nil {
return s.HandleRecoveryError(r, f, body, err)
}
return nil
}
func (s *Strategy) markRecoveryAddressVerified(r *http.Request, f *recovery.Flow, id *identity.Identity, recoveryAddress *identity.RecoveryAddress) error {
for k, v := range id.VerifiableAddresses {
if v.Value == recoveryAddress.Value {
id.VerifiableAddresses[k].Verified = true
id.VerifiableAddresses[k].VerifiedAt = new(sqlxx.NullTime(time.Now().UTC()))
id.VerifiableAddresses[k].Status = identity.VerifiableAddressStatusCompleted
if err := s.d.PrivilegedIdentityPool().UpdateVerifiableAddress(r.Context(), &id.VerifiableAddresses[k], "verified", "verified_at", "status"); err != nil {
return s.HandleRecoveryError(r, f, nil, err)
}
}
}
return nil
}
func (s *Strategy) HandleRecoveryError(r *http.Request, req *recovery.Flow, body *recoverySubmitPayload, err error) error {
if req != nil {
email := ""
if body != nil {
email = body.Email
}
req.UI.SetCSRF(s.d.GenerateCSRFToken(r))
req.UI.GetNodes().Upsert(
// v0.5: form.Field{Name: "email", Type: "email", Required: true, Value: body.Body.Email}
node.NewInputField("email", email, node.LinkGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute).WithMetaLabel(text.NewInfoNodeInputEmail()),
)
}
return err
}
type recoverySubmitPayload struct {
Method string `json:"method" form:"method"`
Token string `json:"token" form:"token"`
CSRFToken string `json:"csrf_token" form:"csrf_token"`
Flow string `json:"flow" form:"flow"`
Email string `json:"email" form:"email"`
TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"`
}
func (s *Strategy) decodeRecovery(r *http.Request) (*recoverySubmitPayload, error) {
var body recoverySubmitPayload
compiler, err := decoderx.HTTPRawJSONSchemaCompiler(recoveryMethodSchema)
if err != nil {
return nil, errors.WithStack(err)
}
if err := decoderx.Decode(r, &body, compiler,
decoderx.HTTPDecoderUseQueryAndBody(),
decoderx.HTTPKeepRequestBody(true),
decoderx.HTTPDecoderAllowedMethods("POST", "GET"),
decoderx.HTTPDecoderSetValidatePayloads(true),
decoderx.HTTPDecoderJSONFollowsFormFormat(),
); err != nil {
return nil, errors.WithStack(err)
}
return &body, nil
}

View File

@@ -0,0 +1,924 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/davecgh/go-spew/spew"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/corpx"
"github.com/ory/kratos/driver"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
kratos "github.com/ory/kratos/pkg/httpclient"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/selfservice/strategy/link"
"github.com/ory/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/assertx"
"github.com/ory/x/clock"
"github.com/ory/x/contextx"
"github.com/ory/x/ioutilx"
"github.com/ory/x/sqlxx"
"github.com/ory/x/urlx"
)
func init() {
corpx.RegisterFakes()
}
func createIdentityToRecover(t *testing.T, reg *driver.RegistryDefault, email string) *identity.Identity {
id := &identity.Identity{
Credentials: map[identity.CredentialsType]identity.Credentials{
"password": {Type: "password", Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"foo"}`)},
},
Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, email)),
SchemaID: config.DefaultIdentityTraitsSchemaID,
}
require.NoError(t, reg.IdentityManager().Create(t.Context(), id, identity.ManagerAllowWriteProtectedTraits))
addr, err := reg.IdentityPool().FindVerifiableAddressByValue(t.Context(), identity.AddressTypeEmail, email)
assert.NoError(t, err)
assert.False(t, addr.Verified)
assert.Nil(t, addr.VerifiedAt)
assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status)
return id
}
func TestAdminStrategy(t *testing.T) {
ctx := t.Context()
conf, reg := pkg.NewFastRegistryWithMocks(t)
initViper(t, conf)
_ = testhelpers.NewRecoveryUIFlowEchoServer(t, reg)
_ = testhelpers.NewSettingsUIFlowEchoServer(t, reg)
_ = testhelpers.NewLoginUIFlowEchoServer(t, reg)
_ = testhelpers.NewErrorTestServer(t, reg)
publicTS, adminTS := testhelpers.NewKratosServer(t, reg)
adminSDK := testhelpers.NewSDKClient(adminTS)
checkLink := func(t *testing.T, l *kratos.RecoveryLinkForIdentity, isBefore time.Time) {
require.Contains(t, l.RecoveryLink, publicTS.URL+recovery.RouteSubmitFlow)
rl := urlx.ParseOrPanic(l.RecoveryLink)
assert.NotEmpty(t, rl.Query().Get("token"))
assert.NotEmpty(t, rl.Query().Get("flow"))
require.True(t, (*l.ExpiresAt).Before(isBefore))
}
t.Run("no panic on empty body #1384", func(t *testing.T) {
s, ps, err := reg.RecoveryStrategies(t.Context()).ActiveStrategies("link")
require.NoError(t, err)
r := &http.Request{URL: new(url.URL)}
f, err := recovery.NewFlow(reg, time.Minute, "", r, s, flow.TypeBrowser)
require.NoError(t, err)
require.IsType(t, &link.Strategy{}, ps)
expectedErr := errors.New("test")
err = ps.(*link.Strategy).HandleRecoveryError(r, f, nil, expectedErr)
require.ErrorIs(t, err, expectedErr)
})
t.Run("description=should not be able to recover an account that does not exist", func(t *testing.T) {
_, _, err := adminSDK.IdentityAPI.CreateRecoveryLinkForIdentity(t.Context()).CreateRecoveryLinkForIdentityBody(kratos.CreateRecoveryLinkForIdentityBody{
IdentityId: x.NewUUID().String(),
}).Execute()
require.IsTypef(t, err, new(kratos.GenericOpenAPIError), "%T", err)
assert.EqualError(t, err, "400 Bad Request")
})
t.Run("description=should create a valid recovery link without email", func(t *testing.T) {
id := identity.Identity{Traits: identity.Traits(`{}`)}
require.NoError(t, reg.IdentityManager().Create(t.Context(),
&id, identity.ManagerAllowWriteProtectedTraits))
rl, _, err := adminSDK.IdentityAPI.CreateRecoveryLinkForIdentity(t.Context()).CreateRecoveryLinkForIdentityBody(kratos.CreateRecoveryLinkForIdentityBody{
IdentityId: id.ID.String(),
ExpiresIn: new("100ms"),
}).Execute()
require.NoError(t, err)
time.Sleep(time.Millisecond * 100)
checkLink(t, rl, time.Now().Add(conf.SelfServiceFlowRecoveryRequestLifespan(ctx)))
res, err := publicTS.Client().Get(rl.RecoveryLink)
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
// We end up here because the link is expired.
assert.Contains(t, res.Request.URL.Path, "/recover", rl.RecoveryLink)
})
t.Run("description=should create a valid recovery link and set the expiry time and not be able to recover the account", func(t *testing.T) {
recoveryEmail := "recover.expired@ory.sh"
id := identity.Identity{Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, recoveryEmail))}
require.NoError(t, reg.IdentityManager().Create(t.Context(),
&id, identity.ManagerAllowWriteProtectedTraits))
rl, _, err := adminSDK.IdentityAPI.CreateRecoveryLinkForIdentity(t.Context()).CreateRecoveryLinkForIdentityBody(kratos.CreateRecoveryLinkForIdentityBody{
IdentityId: id.ID.String(),
ExpiresIn: new("100ms"),
}).Execute()
require.NoError(t, err)
time.Sleep(time.Millisecond * 100)
checkLink(t, rl, time.Now().Add(conf.SelfServiceFlowRecoveryRequestLifespan(ctx)))
res, err := publicTS.Client().Get(rl.RecoveryLink)
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
// We end up here because the link is expired.
assert.Contains(t, res.Request.URL.Path, "/recover", rl.RecoveryLink)
addr, err := reg.IdentityPool().FindVerifiableAddressByValue(t.Context(), identity.AddressTypeEmail, recoveryEmail)
assert.NoError(t, err)
assert.False(t, addr.Verified)
assert.Nil(t, addr.VerifiedAt)
assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status)
})
t.Run("description=should create a valid recovery link and set the expiry time as well and recover the account", func(t *testing.T) {
recoveryEmail := "recoverme@ory.sh"
id := identity.Identity{Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, recoveryEmail))}
require.NoError(t, reg.IdentityManager().Create(t.Context(),
&id, identity.ManagerAllowWriteProtectedTraits))
rl, _, err := adminSDK.IdentityAPI.CreateRecoveryLinkForIdentity(t.Context()).CreateRecoveryLinkForIdentityBody(kratos.CreateRecoveryLinkForIdentityBody{
IdentityId: id.ID.String(),
}).Execute()
require.NoError(t, err)
checkLink(t, rl, time.Now().Add(conf.SelfServiceFlowRecoveryRequestLifespan(ctx)+time.Second))
res, err := publicTS.Client().Get(rl.RecoveryLink)
require.NoError(t, err)
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowSettingsUI(ctx).String())
assert.Equal(t, http.StatusOK, res.StatusCode)
testhelpers.LogJSON(t, rl)
f, err := reg.SettingsFlowPersister().GetSettingsFlow(t.Context(), uuid.FromStringOrNil(res.Request.URL.Query().Get("flow")))
require.NoError(t, err, "%s", res.Request.URL.String())
require.Len(t, f.UI.Messages, 1)
assert.Equal(t, "You successfully recovered your account. Please change your password or set up an alternative login method (e.g. social sign in) within the next 60.00 minutes.", f.UI.Messages[0].Text)
addr, err := reg.IdentityPool().FindVerifiableAddressByValue(t.Context(), identity.AddressTypeEmail, recoveryEmail)
assert.NoError(t, err)
assert.False(t, addr.Verified)
assert.Nil(t, addr.VerifiedAt)
assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status)
})
t.Run("case=should not be able to use code from different flow", func(t *testing.T) {
email := strings.ToLower(testhelpers.RandomEmail())
id := createIdentityToRecover(t, reg, email)
rl1, _, err := adminSDK.IdentityAPI.
CreateRecoveryLinkForIdentity(t.Context()).
CreateRecoveryLinkForIdentityBody(kratos.CreateRecoveryLinkForIdentityBody{
IdentityId: id.ID.String(),
}).
Execute()
require.NoError(t, err)
checkLink(t, rl1, time.Now().Add(conf.SelfServiceFlowRecoveryRequestLifespan(ctx)+time.Second))
rl2, _, err := adminSDK.IdentityAPI.
CreateRecoveryLinkForIdentity(t.Context()).
CreateRecoveryLinkForIdentityBody(kratos.CreateRecoveryLinkForIdentityBody{
IdentityId: id.ID.String(),
}).
Execute()
require.NoError(t, err)
checkLink(t, rl2, time.Now().Add(conf.SelfServiceFlowRecoveryRequestLifespan(ctx)+time.Second))
recoveryUrl1, err := url.Parse(rl1.RecoveryLink)
require.NoError(t, err)
recoveryUrl2, err := url.Parse(rl2.RecoveryLink)
require.NoError(t, err)
token1 := recoveryUrl1.Query().Get("token")
require.NotEmpty(t, token1)
token2 := recoveryUrl2.Query().Get("token")
require.NotEmpty(t, token2)
require.NotEqual(t, token1, token2)
values := recoveryUrl1.Query()
values.Set("token", token2)
recoveryUrl1.RawQuery = values.Encode()
action := recoveryUrl1.String()
// Submit the modified link with token from rl2 and flow from rl1
res, err := publicTS.Client().Get(action)
require.NoError(t, err)
body := ioutilx.MustReadAll(res.Body)
action = gjson.GetBytes(body, "ui.action").String()
require.NotEmpty(t, action)
assert.Equal(t, "The recovery token is invalid or has already been used. Please retry the flow.", gjson.GetBytes(body, "ui.messages.0.text").String())
})
}
func TestRecovery(t *testing.T) {
ctx := t.Context()
conf, reg := pkg.NewFastRegistryWithMocks(t)
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".code.enabled", false)
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+".link.enabled", true)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/default.schema.json")
initViper(t, conf)
_ = testhelpers.NewRecoveryUIFlowEchoServer(t, reg)
_ = testhelpers.NewLoginUIFlowEchoServer(t, reg)
_ = testhelpers.NewSettingsUIFlowEchoServer(t, reg)
_ = testhelpers.NewErrorTestServer(t, reg)
public, _, publicRouter, _ := testhelpers.NewKratosServerWithCSRFAndRouters(t, reg)
expect := func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values), c int) string {
if hc == nil {
hc = testhelpers.NewDebugClient(t)
if !isAPI {
hc = testhelpers.NewClientWithCookies(t)
hc.Transport = testhelpers.NewTransportWithLogger(testhelpers.NewTestTransport(t), t).RoundTripper
}
}
return testhelpers.SubmitRecoveryForm(t, isAPI, isSPA, hc, public, values, c,
testhelpers.ExpectURL(isAPI || isSPA, public.URL+recovery.RouteSubmitFlow, conf.SelfServiceFlowRecoveryUI(ctx).String()))
}
expectValidationError := func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string {
return expect(t, hc, isAPI, isSPA, values, testhelpers.ExpectStatusCode(isAPI || isSPA, http.StatusBadRequest, http.StatusOK))
}
expectSuccess := func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string {
return expect(t, hc, isAPI, isSPA, values, http.StatusOK)
}
t.Run("description=should set all the correct recovery payloads after submission", func(t *testing.T) {
body := expectSuccess(t, nil, false, false, func(v url.Values) {
v.Set("email", "test@ory.sh")
})
testhelpers.SnapshotTExcept(t, json.RawMessage(gjson.Get(body, "ui.nodes").String()), []string{"0.attributes.value"})
})
t.Run("description=should set all the correct recovery payloads", func(t *testing.T) {
c := testhelpers.NewClientWithCookies(t)
rs := testhelpers.GetRecoveryFlow(t, c, public)
testhelpers.SnapshotTExcept(t, rs.Ui.Nodes, []string{"0.attributes.value"})
assert.EqualValues(t, public.URL+recovery.RouteSubmitFlow+"?flow="+rs.Id, rs.Ui.Action)
assert.Empty(t, rs.Ui.Messages)
})
t.Run("description=should require an email to be sent", func(t *testing.T) {
check := func(t *testing.T, actual string) {
assert.EqualValues(t, node.LinkGroup, gjson.Get(actual, "active").String(), "%s", actual)
assert.EqualValues(t, "Property email is missing.",
gjson.Get(actual, "ui.nodes.#(attributes.name==email).messages.0.text").String(),
"%s", actual)
}
values := func(v url.Values) {
v.Del("email")
}
t.Run("type=browser", func(t *testing.T) {
check(t, expectValidationError(t, nil, false, false, values))
})
t.Run("type=spa", func(t *testing.T) {
check(t, expectValidationError(t, nil, false, true, values))
})
t.Run("type=api", func(t *testing.T) {
check(t, expectValidationError(t, nil, true, false, values))
})
})
t.Run("description=should require a valid email to be sent", func(t *testing.T) {
check := func(t *testing.T, actual string, value string) {
assert.EqualValues(t, node.LinkGroup, gjson.Get(actual, "active").String(), "%s", actual)
assert.EqualValues(t, "Enter a valid email address",
gjson.Get(actual, "ui.nodes.#(attributes.name==email).messages.0.text").String(),
"%s", actual)
}
for _, email := range []string{"\\", "asdf", "...", "aiacobelli.sec@gmail.com,alejandro.iacobelli@mercadolibre.com"} {
values := func(v url.Values) {
v.Set("email", email)
}
t.Run("type=browser", func(t *testing.T) {
check(t, expectValidationError(t, nil, false, false, values), email)
})
t.Run("type=spa", func(t *testing.T) {
check(t, expectValidationError(t, nil, false, true, values), email)
})
t.Run("type=api", func(t *testing.T) {
check(t, expectValidationError(t, nil, true, false, values), email)
})
}
})
t.Run("description=should try to submit the form while authenticated", func(t *testing.T) {
run := func(t *testing.T, flow string) {
isAPI := flow == "api"
isSPA := flow == "spa"
hc := testhelpers.NewDebugClient(t)
if !isAPI {
hc = testhelpers.NewClientWithCookies(t)
hc.Transport = testhelpers.NewTransportWithLogger(testhelpers.NewTestTransport(t), t).RoundTripper
}
var f *kratos.RecoveryFlow
if isAPI {
f = testhelpers.InitializeRecoveryFlowViaAPI(t, hc, public)
} else {
f = testhelpers.InitializeRecoveryFlowViaBrowser(t, hc, isSPA, public, nil)
}
v := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
v.Set("email", "some-email@example.org")
v.Set("method", "link")
authClient := testhelpers.NewHTTPClientWithArbitrarySessionToken(ctx, t, reg)
if isAPI {
req := httptest.NewRequest("GET", "/sessions/whoami", nil).WithContext(contextx.WithConfigValue(ctx, config.ViperKeySessionLifespan, time.Hour))
s, err := testhelpers.NewActiveSession(req, reg,
&identity.Identity{ID: x.NewUUID(), State: identity.StateActive, NID: x.NewUUID()},
time.Now(),
identity.CredentialsTypePassword,
identity.AuthenticatorAssuranceLevel1,
)
require.NoError(t, err)
authClient = testhelpers.NewHTTPClientWithSessionCookieLocalhost(ctx, t, reg, s)
}
body, res := testhelpers.RecoveryMakeRequest(t, isAPI || isSPA, f, authClient, testhelpers.EncodeFormAsJSON(t, isAPI || isSPA, v))
if isAPI || isSPA {
require.EqualValuesf(t, http.StatusBadRequest, res.StatusCode, "%s", body)
assert.Contains(t, res.Request.URL.String(), recovery.RouteSubmitFlow, "%+v\n\t%s", res.Request, body)
assertx.EqualAsJSONExcept(t, recovery.ErrAlreadyLoggedIn(), json.RawMessage(gjson.Get(body, "error").Raw), nil)
} else {
assert.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), "%+v\n\t%s", res.Request, body)
}
}
for _, f := range []string{"browser", "spa", "api"} {
t.Run("type="+f, func(t *testing.T) {
run(t, f)
})
}
})
t.Run("description=should try to recover an email that does not exist", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, true)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, false)
})
var email string
check := func(t *testing.T, actual string) {
assert.EqualValues(t, node.LinkGroup, gjson.Get(actual, "active").String(), "%s", actual)
assert.EqualValues(t, email, gjson.Get(actual, "ui.nodes.#(attributes.name==email).attributes.value").String(), "%s", actual)
assertx.EqualAsJSON(t, text.NewRecoveryEmailSent(), json.RawMessage(gjson.Get(actual, "ui.messages.0").Raw))
message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Account access attempted")
assert.Contains(t, message.Body, "If this was you, check if you signed up using a different address.")
}
values := func(v url.Values) {
v.Set("email", email)
}
t.Run("type=browser", func(t *testing.T) {
email = x.NewUUID().String() + "@ory.sh"
check(t, expectSuccess(t, nil, false, false, values))
})
t.Run("type=spa", func(t *testing.T) {
email = x.NewUUID().String() + "@ory.sh"
check(t, expectSuccess(t, nil, false, true, values))
})
t.Run("type=api", func(t *testing.T) {
email = x.NewUUID().String() + "@ory.sh"
check(t, expectSuccess(t, nil, true, false, values))
})
})
t.Run("description=should not be able to recover an inactive account", func(t *testing.T) {
for _, name := range []string{"browser", "spa", "api"} {
t.Run("case="+name, func(t *testing.T) {
recoveryEmail := "recovery_" + name + "@ory.sh"
id := createIdentityToRecover(t, reg, recoveryEmail)
expectSuccess(t, nil, name == "api", name == "spa", func(v url.Values) {
v.Set("email", recoveryEmail)
})
recoveryLink := testhelpers.CourierExpectLinkInMessage(t, testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account"), 1)
cl := testhelpers.NewClientWithCookies(t)
// Deactivate the identity
require.NoError(t, reg.Persister().GetConnection(t.Context()).RawQuery("UPDATE identities SET state=? WHERE id = ?", identity.StateInactive, id.ID).Exec())
res, err := cl.Get(recoveryLink)
require.NoError(t, err)
body := ioutilx.MustReadAll(res.Body)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowRecoveryUI(ctx).String())
assert.Equal(t, text.NewErrorValidationIdentityDisabled().Text, gjson.GetBytes(body, "ui.messages.0.text").String(), "%s", spew.Sdump(body))
})
}
})
t.Run("description=should recover an account", func(t *testing.T) {
check := func(t *testing.T, recoverySubmissionResponse, recoveryEmail, returnTo string) {
addr, err := reg.IdentityPool().FindVerifiableAddressByValue(t.Context(), identity.AddressTypeEmail, recoveryEmail)
assert.NoError(t, err)
assert.False(t, addr.Verified)
assert.Nil(t, addr.VerifiedAt)
assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status)
assert.EqualValues(t, node.LinkGroup, gjson.Get(recoverySubmissionResponse, "active").String(), "%s", recoverySubmissionResponse)
assert.EqualValues(t, recoveryEmail, gjson.Get(recoverySubmissionResponse, "ui.nodes.#(attributes.name==email).attributes.value").String(), "%s", recoverySubmissionResponse)
require.Len(t, gjson.Get(recoverySubmissionResponse, "ui.messages").Array(), 1, "%s", recoverySubmissionResponse)
assertx.EqualAsJSON(t, text.NewRecoveryEmailSent(), json.RawMessage(gjson.Get(recoverySubmissionResponse, "ui.messages.0").Raw))
message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account")
assert.Contains(t, message.Body, "Recover access to your account by clicking the following link")
recoveryLink := testhelpers.CourierExpectLinkInMessage(t, message, 1)
assert.Contains(t, recoveryLink, public.URL+recovery.RouteSubmitFlow)
assert.Contains(t, recoveryLink, "token=")
cl := testhelpers.NewClientWithCookies(t)
res, err := cl.Get(recoveryLink)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowSettingsUI(ctx).String())
body := ioutilx.MustReadAll(res.Body)
assert.Equal(t, text.NewRecoverySuccessful(clock.New(), time.Now().Add(time.Hour)).Text,
gjson.GetBytes(body, "ui.messages.0.text").String())
assert.Equal(t, returnTo, gjson.GetBytes(body, "return_to").String())
addr, err = reg.IdentityPool().FindVerifiableAddressByValue(t.Context(), identity.AddressTypeEmail, recoveryEmail)
assert.NoError(t, err)
assert.True(t, addr.Verified)
assert.NotEqual(t, sqlxx.NullTime{}, addr.VerifiedAt)
assert.Equal(t, identity.VerifiableAddressStatusCompleted, addr.Status)
res, err = cl.Get(public.URL + session.RouteWhoami)
require.NoError(t, err)
body = x.MustReadAll(res.Body)
require.NoError(t, res.Body.Close())
assert.Equal(t, "link_recovery", gjson.GetBytes(body, "authentication_methods.0.method").String(), "%s", body)
assert.Equal(t, "aal1", gjson.GetBytes(body, "authenticator_assurance_level").String(), "%s", body)
}
t.Run("type=browser", func(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
testhelpers.NewRecoveryAfterHookWebHookTarget(ctx, t, conf, func(t *testing.T, msg []byte) {
defer wg.Done()
assert.EqualValues(t, "recoverme1@ory.sh", gjson.GetBytes(msg, "identity.verifiable_addresses.0.value").String(), string(msg))
assert.EqualValues(t, true, gjson.GetBytes(msg, "identity.verifiable_addresses.0.verified").Bool(), string(msg))
assert.EqualValues(t, "completed", gjson.GetBytes(msg, "identity.verifiable_addresses.0.status").String(), string(msg))
})
email := "recoverme1@ory.sh"
createIdentityToRecover(t, reg, email)
check(t, expectSuccess(t, nil, false, false, func(v url.Values) {
v.Set("email", email)
}), email, "")
wg.Wait()
})
t.Run("description=should return browser to return url", func(t *testing.T) {
returnTo := public.URL + "/return-to"
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnTo})
for _, tc := range []struct {
desc string
returnTo string
f func(t *testing.T, client *http.Client) *kratos.RecoveryFlow
}{
{
desc: "should use return_to from recovery flow",
returnTo: returnTo,
f: func(t *testing.T, client *http.Client) *kratos.RecoveryFlow {
return testhelpers.InitializeRecoveryFlowViaBrowser(t, client, false, public, url.Values{"return_to": []string{returnTo}})
},
},
{
desc: "should use return_to from config",
returnTo: returnTo,
f: func(t *testing.T, client *http.Client) *kratos.RecoveryFlow {
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryBrowserDefaultReturnTo, returnTo)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryBrowserDefaultReturnTo, "")
})
return testhelpers.InitializeRecoveryFlowViaBrowser(t, client, false, public, nil)
},
},
{
desc: "no return to",
returnTo: "",
f: func(t *testing.T, client *http.Client) *kratos.RecoveryFlow {
return testhelpers.InitializeRecoveryFlowViaBrowser(t, client, false, public, nil)
},
},
} {
t.Run(tc.desc, func(t *testing.T) {
email := testhelpers.RandomEmail()
createIdentityToRecover(t, reg, email)
hc := testhelpers.NewClientWithCookies(t)
hc.Transport = testhelpers.NewTransportWithLogger(testhelpers.NewTestTransport(t), t).RoundTripper
f := tc.f(t, hc)
time.Sleep(time.Millisecond) // add a bit of delay to allow `1ns` to time out.
formPayload := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
formPayload.Set("email", email)
b, res := testhelpers.RecoveryMakeRequest(t, false, f, hc, testhelpers.EncodeFormAsJSON(t, false, formPayload))
assert.EqualValues(t, http.StatusOK, res.StatusCode, "%s", b)
expectedURL := testhelpers.ExpectURL(false, public.URL+recovery.RouteSubmitFlow, conf.SelfServiceFlowRecoveryUI(ctx).String())
assert.Contains(t, res.Request.URL.String(), expectedURL, "%+v\n\t%s", res.Request, b)
check(t, b, email, tc.returnTo)
})
}
})
t.Run("type=spa", func(t *testing.T) {
email := "recoverme3@ory.sh"
createIdentityToRecover(t, reg, email)
check(t, expectSuccess(t, nil, true, true, func(v url.Values) {
v.Set("email", email)
}), email, "")
})
t.Run("type=api", func(t *testing.T) {
email := "recoverme4@ory.sh"
createIdentityToRecover(t, reg, email)
check(t, expectSuccess(t, nil, true, false, func(v url.Values) {
v.Set("email", email)
}), email, "")
})
})
t.Run("description=should recover an account and set the csrf cookies", func(t *testing.T) {
check := func(t *testing.T, actual, recoveryEmail string, cl *http.Client, do func(*http.Client, *http.Request) (*http.Response, error)) {
message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account")
recoveryLink := testhelpers.CourierExpectLinkInMessage(t, message, 1)
cl.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
res, err := do(cl, testhelpers.NewTestHTTPRequest(t, "GET", recoveryLink, nil))
require.NoError(t, err)
require.NoError(t, res.Body.Close())
assert.Equal(t, http.StatusSeeOther, res.StatusCode)
require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2)
cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)))
assert.Contains(t, cookies, nosurfx.CSRFTokenName)
assert.Contains(t, cookies, "ory_kratos_session")
returnTo, err := res.Location()
require.NoError(t, err)
assert.Contains(t, returnTo.String(), conf.SelfServiceFlowSettingsUI(ctx).String(), "we end up at the settings screen")
rl := urlx.ParseOrPanic(recoveryLink)
actualRes, err := cl.Get(public.URL + recovery.RouteGetFlow + "?id=" + rl.Query().Get("flow"))
require.NoError(t, err)
body := x.MustReadAll(actualRes.Body)
require.NoError(t, actualRes.Body.Close())
assert.Equal(t, http.StatusOK, actualRes.StatusCode, "%s", body)
assert.Equal(t, string(flow.StatePassedChallenge), gjson.GetBytes(body, "state").String(), "%s", body)
}
email := x.NewUUID().String() + "@ory.sh"
id := createIdentityToRecover(t, reg, email)
t.Run("case=unauthenticated", func(t *testing.T) {
values := func(v url.Values) {
v.Set("email", email)
}
check(t, expectSuccess(t, nil, false, false, values), email, testhelpers.NewClientWithCookies(t), (*http.Client).Do)
})
t.Run("case=already logged into another account", func(t *testing.T) {
values := func(v url.Values) {
v.Set("email", email)
}
check(t, expectSuccess(t, nil, false, false, values), email, testhelpers.NewClientWithCookies(t), func(cl *http.Client, req *http.Request) (*http.Response, error) {
_, res := testhelpers.MockMakeAuthenticatedRequestWithClient(t, reg, conf, publicRouter, req, cl)
return res, nil
})
})
t.Run("case=already logged into the account", func(t *testing.T) {
values := func(v url.Values) {
v.Set("email", email)
}
cl := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, reg, id)
check(t, expectSuccess(t, nil, false, false, values), email, cl, func(_ *http.Client, req *http.Request) (*http.Response, error) {
_, res := testhelpers.MockMakeAuthenticatedRequestWithClientAndID(t, reg, conf, publicRouter, req, cl, id)
return res, nil
})
})
})
t.Run("description=should recover and invalidate all other sessions if hook is set", func(t *testing.T) {
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), []config.SelfServiceHook{{Name: "revoke_active_sessions"}})
t.Cleanup(func() {
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil)
})
recoveryEmail := strings.ToLower(testhelpers.RandomEmail())
email := recoveryEmail
id := createIdentityToRecover(t, reg, email)
req := httptest.NewRequest("GET", "/sessions/whoami", nil)
sess, err := testhelpers.NewActiveSession(req, reg, id, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
require.NoError(t, reg.SessionPersister().UpsertSession(t.Context(), sess))
actualSession, err := reg.SessionPersister().GetSession(t.Context(), sess.ID, session.ExpandNothing)
require.NoError(t, err)
assert.True(t, actualSession.IsActive())
check := func(t *testing.T, actual string) {
message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account")
recoveryLink := testhelpers.CourierExpectLinkInMessage(t, message, 1)
cl := testhelpers.NewClientWithCookies(t)
cl.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
res, err := cl.Get(recoveryLink)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
assert.Equal(t, http.StatusSeeOther, res.StatusCode)
require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2)
cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)))
assert.Contains(t, cookies, "ory_kratos_session")
actualSession, err := reg.SessionPersister().GetSession(t.Context(), sess.ID, session.ExpandNothing)
require.NoError(t, err)
assert.False(t, actualSession.IsActive())
}
values := func(v url.Values) {
v.Set("email", recoveryEmail)
}
check(t, expectSuccess(t, nil, false, false, values))
})
t.Run("description=should not be able to use an invalid link", func(t *testing.T) {
c := testhelpers.NewClientWithCookies(t)
f := testhelpers.InitializeRecoveryFlowViaBrowser(t, c, false, public, nil)
res, err := c.Get(f.Ui.Action + "&token=i-do-not-exist")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowRecoveryUI(ctx).String()+"?flow=")
rs, _, err := testhelpers.NewSDKCustomClient(public, c).FrontendAPI.GetRecoveryFlow(t.Context()).Id(res.Request.URL.Query().Get("flow")).Execute()
require.NoError(t, err)
require.Len(t, rs.Ui.Messages, 1)
assert.Equal(t, "The recovery token is invalid or has already been used. Please retry the flow.", rs.Ui.Messages[0].Text)
})
t.Run("description=should not be able to use an outdated link", func(t *testing.T) {
recoveryEmail := "recoverme5@ory.sh"
createIdentityToRecover(t, reg, recoveryEmail)
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Millisecond*200)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Minute)
})
c := testhelpers.NewClientWithCookies(t)
rs := testhelpers.GetRecoveryFlow(t, c, public)
time.Sleep(time.Millisecond * 201)
res, err := c.PostForm(rs.Ui.Action, url.Values{"email": {recoveryEmail}})
require.NoError(t, err)
assert.EqualValues(t, http.StatusOK, res.StatusCode)
assert.NotContains(t, res.Request.URL.String(), "flow="+rs.Id)
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowRecoveryUI(ctx).String())
addr, err := reg.IdentityPool().FindVerifiableAddressByValue(t.Context(), identity.AddressTypeEmail, recoveryEmail)
assert.NoError(t, err)
assert.False(t, addr.Verified)
assert.Nil(t, addr.VerifiedAt)
assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status)
})
t.Run("description=should not be able to use an outdated flow", func(t *testing.T) {
recoveryEmail := "recoverme6@ory.sh"
createIdentityToRecover(t, reg, recoveryEmail)
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Millisecond*200)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceRecoveryRequestLifespan, time.Minute)
})
c := testhelpers.NewClientWithCookies(t)
body := expectSuccess(t, c, false, false, func(v url.Values) {
v.Set("email", recoveryEmail)
})
message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account")
assert.Contains(t, message.Body, "Recover access to your account by clicking the following link")
recoveryLink := testhelpers.CourierExpectLinkInMessage(t, message, 1)
time.Sleep(time.Millisecond * 201)
res, err := c.Get(recoveryLink)
require.NoError(t, err)
assert.EqualValues(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowRecoveryUI(ctx).String())
assert.NotContains(t, res.Request.URL.String(), gjson.Get(body, "id").String())
rs, _, err := testhelpers.NewSDKCustomClient(public, c).FrontendAPI.GetRecoveryFlow(t.Context()).Id(res.Request.URL.Query().Get("flow")).Execute()
require.NoError(t, err)
require.Len(t, rs.Ui.Messages, 1)
assert.Contains(t, rs.Ui.Messages[0].Text, "The recovery flow expired")
addr, err := reg.IdentityPool().FindVerifiableAddressByValue(t.Context(), identity.AddressTypeEmail, recoveryEmail)
assert.NoError(t, err)
assert.False(t, addr.Verified)
assert.Nil(t, addr.VerifiedAt)
assert.Equal(t, identity.VerifiableAddressStatusPending, addr.Status)
})
t.Run("description=should recover if post recovery hook is successful", func(t *testing.T) {
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}})
t.Cleanup(func() {
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), nil)
})
recoveryEmail := testhelpers.RandomEmail()
createIdentityToRecover(t, reg, recoveryEmail)
check := func(t *testing.T, actual string) {
message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account")
recoveryLink := testhelpers.CourierExpectLinkInMessage(t, message, 1)
cl := testhelpers.NewClientWithCookies(t)
cl.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
res, err := cl.Get(recoveryLink)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
assert.Equal(t, http.StatusSeeOther, res.StatusCode)
require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 2)
cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)))
assert.Contains(t, cookies, "ory_kratos_session")
}
values := func(v url.Values) {
v.Set("email", recoveryEmail)
}
check(t, expectSuccess(t, nil, false, false, values))
})
t.Run("description=should not be able to recover if post recovery hook fails", func(t *testing.T) {
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), []config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecutePostRecoveryHook": "err"}`)}})
t.Cleanup(func() {
conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRecoveryAfter, config.HookGlobal), nil)
})
recoveryEmail := testhelpers.RandomEmail()
createIdentityToRecover(t, reg, recoveryEmail)
check := func(t *testing.T, actual string) {
message := testhelpers.CourierExpectMessage(ctx, t, reg, recoveryEmail, "Recover access to your account")
recoveryLink := testhelpers.CourierExpectLinkInMessage(t, message, 1)
cl := testhelpers.NewClientWithCookies(t)
cl.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
res, err := cl.Get(recoveryLink)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
assert.Equal(t, http.StatusSeeOther, res.StatusCode)
require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 1)
cookies := spew.Sdump(cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)))
assert.NotContains(t, cookies, "ory_kratos_session")
}
values := func(v url.Values) {
v.Set("email", recoveryEmail)
}
check(t, expectSuccess(t, nil, false, false, values))
})
}
func TestDisabledEndpoint(t *testing.T) {
ctx := t.Context()
conf, reg := pkg.NewFastRegistryWithMocks(t)
initViper(t, conf)
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(recovery.RecoveryStrategyLink)+".enabled", false)
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(recovery.RecoveryStrategyCode)+".enabled", false)
publicTS, adminTS := testhelpers.NewKratosServer(t, reg)
adminSDK := testhelpers.NewSDKClient(adminTS)
_ = testhelpers.NewErrorTestServer(t, reg)
t.Run("role=admin", func(t *testing.T) {
t.Run("description=can not create recovery link when link method is disabled", func(t *testing.T) {
id := identity.Identity{Traits: identity.Traits(`{"email":"recovery-endpoint-disabled@ory.sh"}`)}
require.NoError(t, reg.IdentityManager().Create(t.Context(),
&id, identity.ManagerAllowWriteProtectedTraits))
rl, _, err := adminSDK.IdentityAPI.CreateRecoveryLinkForIdentity(t.Context()).CreateRecoveryLinkForIdentityBody(kratos.CreateRecoveryLinkForIdentityBody{
IdentityId: id.ID.String(),
}).Execute()
assert.Nil(t, rl)
require.IsType(t, new(kratos.GenericOpenAPIError), err, "%s", err)
br, _ := err.(*kratos.GenericOpenAPIError)
assert.Contains(t, string(br.Body()), "This endpoint was disabled by system administrator", "%s", br.Body())
})
})
t.Run("role=public", func(t *testing.T) {
c := testhelpers.NewClientWithCookies(t)
t.Run("description=can not recover an account by get request when link method is disabled", func(t *testing.T) {
f := testhelpers.PersistNewRecoveryFlow(t, nil, conf, reg)
u := publicTS.URL + recovery.RouteSubmitFlow + "?flow=" + f.ID.String() + "&token=endpoint-disabled"
res, err := c.Get(u)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
b := ioutilx.MustReadAll(res.Body)
assert.Contains(t, string(b), "This endpoint was disabled by system administrator")
})
t.Run("description=can not recover an account by post request when link method is disabled", func(t *testing.T) {
f := testhelpers.PersistNewRecoveryFlow(t, nil, conf, reg)
u := publicTS.URL + recovery.RouteSubmitFlow + "?flow=" + f.ID.String()
res, err := c.PostForm(u, url.Values{"email": {"email@ory.sh"}, "method": {"link"}})
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
b := ioutilx.MustReadAll(res.Body)
assert.Contains(t, string(b), "This endpoint was disabled by system administrator")
})
})
}

View File

@@ -0,0 +1,35 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow/recovery"
)
func initViper(t *testing.T, c *config.Config) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`OK`))
}))
t.Cleanup(ts.Close)
ctx := context.Background()
testhelpers.SetDefaultIdentitySchema(c, "file://./stub/default.schema.json")
c.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, ts.URL)
c.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{ts.URL})
c.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+identity.CredentialsTypePassword.String()+".enabled", true)
c.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(recovery.RecoveryStrategyLink)+".enabled", true)
c.MustSet(ctx, config.ViperKeySelfServiceRecoveryUse, "link")
c.MustSet(ctx, config.ViperKeySelfServiceRecoveryEnabled, true)
c.MustSet(ctx, config.ViperKeySelfServiceVerificationEnabled, true)
c.MustSet(ctx, config.ViperKeySelfServiceVerificationUse, "link")
}

View File

@@ -0,0 +1,368 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link
import (
"context"
"encoding/json"
"net/http"
"net/url"
"time"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"go.opentelemetry.io/otel/attribute"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/settings"
"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"
"github.com/ory/x/decoderx"
"github.com/ory/x/otelx"
"github.com/ory/x/sqlcon"
"github.com/ory/x/sqlxx"
"github.com/ory/x/urlx"
)
func (s *Strategy) VerificationStrategyID() string {
return string(verification.VerificationStrategyLink)
}
func (s *Strategy) PopulateVerificationMethod(r *http.Request, f *verification.Flow) error {
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
f.UI.GetNodes().Upsert(
// v0.5: form.Field{Name: "email", Type: "email", Required: true}
node.NewInputField("email", nil, node.LinkGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute).WithMetaLabel(text.NewInfoNodeInputEmail()),
)
f.UI.GetNodes().Append(node.NewInputField("method", s.VerificationStrategyID(), node.LinkGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoNodeLabelContinue()))
return nil
}
type verificationSubmitPayload struct {
Method string `json:"method" form:"method"`
Token string `json:"token" form:"token"`
CSRFToken string `json:"csrf_token" form:"csrf_token"`
Flow string `json:"flow" form:"flow"`
Email string `json:"email" form:"email"`
TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"`
}
func (s *Strategy) decodeVerification(r *http.Request) (*verificationSubmitPayload, error) {
var body verificationSubmitPayload
compiler, err := decoderx.HTTPRawJSONSchemaCompiler(verificationMethodSchema)
if err != nil {
return nil, errors.WithStack(err)
}
if err := decoderx.Decode(r, &body, compiler,
decoderx.HTTPDecoderUseQueryAndBody(),
decoderx.HTTPKeepRequestBody(true),
decoderx.HTTPDecoderAllowedMethods("POST", "GET"),
decoderx.HTTPDecoderSetValidatePayloads(true),
decoderx.HTTPDecoderJSONFollowsFormFormat(),
); err != nil {
return nil, errors.WithStack(err)
}
return &body, nil
}
// handleVerificationError is a convenience function for handling all types of errors that may occur (e.g. validation error).
func (s *Strategy) handleVerificationError(r *http.Request, f *verification.Flow, body *verificationSubmitPayload, err error) error {
if f != nil {
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
email := ""
if body != nil {
email = body.Email
}
f.UI.GetNodes().Upsert(
// v0.5: form.Field{Name: "email", Type: "email", Required: true, Value: body.Body.Email}
node.NewInputField("email", email, node.LinkGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute).WithMetaLabel(text.NewInfoNodeInputEmail()),
)
}
return err
}
// Update Verification Flow with Link Method
//
// swagger:model updateVerificationFlowWithLinkMethod
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type updateVerificationFlowWithLinkMethod struct {
// Email to Verify
//
// Needs to be set when initiating the flow. If the email is a registered
// verification email, a verification link will be sent. If the email is not known,
// a email with details on what happened will be sent instead.
//
// format: email
// required: true
Email string `json:"email"`
// Sending the anti-csrf token is only required for browser login flows.
CSRFToken string `form:"csrf_token" json:"csrf_token"`
// Method is the method that should be used for this verification flow
//
// Allowed values are `link` and `code`
//
// required: true
Method verification.VerificationStrategy `json:"method"`
// Transient data to pass along to any webhooks
//
// required: false
TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"`
}
func (s *Strategy) Verify(w http.ResponseWriter, r *http.Request, f *verification.Flow) (err error) {
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.link.Strategy.Verify")
span.SetAttributes(attribute.String("selfservice_flows_verification_use", s.d.Config().SelfServiceFlowVerificationUse(ctx)))
defer otelx.End(span, &err)
body, err := s.decodeVerification(r)
if err != nil {
return s.handleVerificationError(r, nil, body, err)
}
f.TransientPayload = body.TransientPayload
if len(body.Token) > 0 {
if err := flow.MethodEnabledAndAllowed(ctx, f.GetFlowName(), s.VerificationStrategyID(), s.VerificationStrategyID(), s.d); err != nil {
return s.handleVerificationError(r, nil, body, err)
}
return s.verificationUseToken(ctx, w, r, body, f)
}
if err := flow.MethodEnabledAndAllowed(ctx, f.GetFlowName(), s.VerificationStrategyID(), body.Method, s.d); err != nil {
return s.handleVerificationError(r, f, body, err)
}
if err := f.Valid(s.d.Clock()); err != nil {
return s.handleVerificationError(r, f, body, err)
}
switch f.State {
case flow.StateChooseMethod, flow.StateEmailSent:
return s.verificationHandleFormSubmission(ctx, r, f)
case flow.StatePassedChallenge:
return s.retryVerificationFlowWithMessage(ctx, w, r, f.Type, text.NewErrorValidationVerificationRetrySuccess())
default:
return s.retryVerificationFlowWithMessage(ctx, w, r, f.Type, text.NewErrorValidationVerificationStateFailure())
}
}
func (s *Strategy) verificationHandleFormSubmission(ctx context.Context, r *http.Request, f *verification.Flow) error {
body, err := s.decodeVerification(r)
if err != nil {
return s.handleVerificationError(r, f, body, err)
}
if len(body.Email) == 0 {
return s.handleVerificationError(r, f, body, schema.NewRequiredError("#/email", "email"))
}
if err := flow.EnsureCSRF(s.d, r, f.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, body.CSRFToken); err != nil {
return s.handleVerificationError(r, f, body, err)
}
if err := s.d.LinkSender().SendVerificationLink(ctx, f, identity.AddressTypeEmail, body.Email); err != nil {
if !errors.Is(err, ErrUnknownAddress) {
return s.handleVerificationError(r, f, body, err)
}
// Continue execution
}
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
f.UI.GetNodes().Upsert(
// v0.5: form.Field{Name: "email", Type: "email", Required: true, Value: body.Body.Email}
node.NewInputField("email", body.Email, node.LinkGroup, node.InputAttributeTypeEmail, node.WithRequiredInputAttribute).WithMetaLabel(text.NewInfoNodeInputEmail()),
)
f.Active = sqlxx.NullString(s.NodeGroup())
f.State = flow.StateEmailSent
f.UI.Messages.Set(text.NewVerificationEmailSent())
if err := s.d.VerificationFlowPersister().UpdateVerificationFlow(ctx, f); err != nil {
return s.handleVerificationError(r, f, body, err)
}
return nil
}
func (s *Strategy) verificationUseToken(ctx context.Context, w http.ResponseWriter, r *http.Request, body *verificationSubmitPayload, f *verification.Flow) error {
token, err := s.d.VerificationTokenPersister().UseVerificationToken(ctx, f.ID, body.Token)
if err != nil {
if errors.Is(err, sqlcon.ErrNoRows()) {
return s.retryVerificationFlowWithMessage(ctx, w, r, flow.TypeBrowser, text.NewErrorValidationVerificationTokenInvalidOrAlreadyUsed())
}
return s.retryVerificationFlowWithError(ctx, w, r, flow.TypeBrowser, err)
}
if err := token.Valid(s.d.Clock()); err != nil {
return s.retryVerificationFlowWithError(ctx, w, r, flow.TypeBrowser, err)
}
var identityID uuid.UUID
var addressVia string
var ptcIdentity *identity.Identity
if token.VerifiableAddress == nil {
ptc, ptcErr := s.d.PendingTraitsChangePersister().GetPendingTraitsChangeByVerificationFlow(ctx, f.ID)
if ptcErr != nil {
if errors.Is(ptcErr, sqlcon.ErrNoRows()) {
return s.retryVerificationFlowWithMessage(ctx, w, r, flow.TypeBrowser, text.NewErrorValidationVerificationTokenInvalidOrAlreadyUsed())
}
return s.retryVerificationFlowWithError(ctx, w, r, f.Type, ptcErr)
}
updatedIdentity, applyErr := s.d.SettingsHookExecutor().ApplyPendingTraitsChange(ctx, w, r, ptc)
if applyErr != nil {
if errors.Is(applyErr, identity.ErrConcurrentModification) ||
errors.Is(applyErr, settings.ErrPendingTraitsChangeSessionInvalid) {
f.UI.Messages.Clear()
f.UI.Messages.Add(text.NewErrorValidationVerificationTokenInvalidOrAlreadyUsed())
if err := s.d.VerificationFlowPersister().UpdateVerificationFlow(ctx, f); err != nil {
return s.retryVerificationFlowWithError(ctx, w, r, f.Type, err)
}
if x.IsBrowserRequest(r) {
http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowVerificationUI(ctx)).String(), http.StatusSeeOther)
} else {
s.d.Writer().Write(w, r, f)
}
return errors.WithStack(flow.ErrCompletedByStrategy)
}
return s.retryVerificationFlowWithError(ctx, w, r, f.Type, applyErr)
}
ptcIdentity = updatedIdentity
addressVia = ptc.NewAddressVia
identityID = ptc.IdentityID
} else {
// Normal path: update the existing verifiable address directly.
address := token.VerifiableAddress
address.Verified = true
verifiedAt := sqlxx.NullTime(time.Now().UTC())
address.VerifiedAt = &verifiedAt
address.Status = identity.VerifiableAddressStatusCompleted
if err := s.d.PrivilegedIdentityPool().UpdateVerifiableAddress(ctx, address, "verified", "verified_at", "status"); err != nil {
return s.retryVerificationFlowWithError(ctx, w, r, flow.TypeBrowser, err)
}
addressVia = address.Via
identityID = address.IdentityID
}
var i *identity.Identity
if ptcIdentity != nil {
i = ptcIdentity
} else {
i, err = s.d.IdentityPool().GetIdentity(ctx, identityID, identity.ExpandDefault)
if err != nil {
return s.retryVerificationFlowWithError(ctx, w, r, flow.TypeBrowser, err)
}
}
// Remainder is shared between both paths.
returnTo := f.ContinueURL(ctx, s.d.Config())
f.UI = &container.Container{
Method: "GET",
Action: returnTo.String(),
}
f.State = flow.StatePassedChallenge
// See https://github.com/ory/kratos/issues/1547
f.SetCSRFToken(flow.GetCSRFToken(s.d, w, r, f.Type))
if addressVia == identity.AddressTypeSMS {
f.UI.Messages.Set(text.NewInfoSelfServiceVerificationPhoneSuccessful())
} else {
f.UI.Messages.Set(text.NewInfoSelfServiceVerificationSuccessful())
}
f.UI.Nodes.Append(node.NewAnchorField("continue", returnTo.String(), node.LinkGroup, text.NewInfoNodeLabelContinue()).
WithMetaLabel(text.NewInfoNodeLabelContinue()))
if err := s.d.VerificationFlowPersister().UpdateVerificationFlow(ctx, f); err != nil {
return s.retryVerificationFlowWithError(ctx, w, r, flow.TypeBrowser, err)
}
if err := s.d.VerificationExecutor().PostVerificationHook(w, r, f, i); err != nil {
return s.retryVerificationFlowWithError(ctx, w, r, f.Type, err)
}
return nil
}
func (s *Strategy) retryVerificationFlowWithMessage(ctx context.Context, w http.ResponseWriter, r *http.Request, ft flow.Type, message *text.Message) error {
s.d.Logger().WithRequest(r).WithField("message", message).Debug("A verification flow is being retried because a validation error occurred.")
f, err := verification.NewFlow(s.d,
s.d.Config().SelfServiceFlowVerificationRequestLifespan(ctx), s.d.CSRFHandler().RegenerateToken(w, r), r, verification.Strategies{s}, ft)
if err != nil {
return s.handleVerificationError(r, f, nil, err)
}
f.UI.Messages.Add(message)
if err := s.d.VerificationFlowPersister().CreateVerificationFlow(ctx, f); err != nil {
return s.handleVerificationError(r, f, nil, err)
}
if ft == flow.TypeBrowser {
http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowVerificationUI(ctx)).String(), http.StatusSeeOther)
} else {
http.Redirect(w, r, urlx.CopyWithQuery(urlx.AppendPaths(s.d.Config().SelfPublicURL(ctx),
verification.RouteGetFlow), url.Values{"id": {f.ID.String()}}).String(), http.StatusSeeOther)
}
return errors.WithStack(flow.ErrCompletedByStrategy)
}
func (s *Strategy) retryVerificationFlowWithError(ctx context.Context, w http.ResponseWriter, r *http.Request, ft flow.Type, verErr error) error {
s.d.Logger().WithRequest(r).WithError(verErr).Debug("A verification flow is being retried because an error occurred.")
f, err := verification.NewFlow(s.d,
s.d.Config().SelfServiceFlowVerificationRequestLifespan(ctx), s.d.CSRFHandler().RegenerateToken(w, r), r, verification.Strategies{s}, ft)
if err != nil {
return s.handleVerificationError(r, f, nil, err)
}
if expired := new(flow.ExpiredError); errors.As(verErr, &expired) {
return s.retryVerificationFlowWithMessage(ctx, w, r, ft, text.NewErrorValidationVerificationFlowExpired(s.d.Clock(), expired.ExpiredAt))
} else {
if err := f.UI.ParseError(node.LinkGroup, verErr); err != nil {
return err
}
}
if err := s.d.VerificationFlowPersister().CreateVerificationFlow(ctx, f); err != nil {
return s.handleVerificationError(r, f, nil, err)
}
if ft == flow.TypeBrowser {
http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowVerificationUI(ctx)).String(), http.StatusSeeOther)
} else {
http.Redirect(w, r, urlx.CopyWithQuery(urlx.AppendPaths(s.d.Config().SelfPublicURL(ctx),
verification.RouteGetFlow), url.Values{"id": {f.ID.String()}}).String(), http.StatusSeeOther)
}
return errors.WithStack(flow.ErrCompletedByStrategy)
}
func (s *Strategy) SendVerificationCode(ctx context.Context, f *verification.Flow, i *identity.Identity, addressLike identity.VerifiableAddressLike) error {
token := NewSelfServiceVerificationToken(addressLike, f, s.d.Config().SelfServiceLinkMethodLifespan(ctx))
if err := s.d.VerificationTokenPersister().CreateVerificationToken(ctx, token); err != nil {
return err
}
return s.d.LinkSender().SendVerificationTokenTo(ctx, f, i, addressLike, token)
}

View File

@@ -0,0 +1,932 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link_test
import (
"context"
_ "embed"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/urlx"
"github.com/ory/kratos/selfservice/strategy/link"
"github.com/ory/kratos/ui/node"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/selfservice/flow/verification"
"github.com/ory/kratos/text"
"github.com/ory/kratos/x"
"github.com/ory/x/assertx"
"github.com/ory/x/ioutilx"
"github.com/ory/x/sqlxx"
)
func TestVerification(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
initViper(t, conf)
identityToVerify := &identity.Identity{
ID: x.NewUUID(),
Traits: identity.Traits(`{"email":"verifyme@ory.sh"}`),
SchemaID: config.DefaultIdentityTraitsSchemaID,
Credentials: map[identity.CredentialsType]identity.Credentials{
"password": {Type: "password", Identifiers: []string{"recoverme@ory.sh"}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"foo"}`)},
},
}
verificationEmail := gjson.GetBytes(identityToVerify.Traits, "email").String()
_ = testhelpers.NewVerificationUIFlowEchoServer(t, reg)
_ = testhelpers.NewLoginUIFlowEchoServer(t, reg)
_ = testhelpers.NewSettingsUIFlowEchoServer(t, reg)
_ = testhelpers.NewErrorTestServer(t, reg)
_ = testhelpers.NewRedirTS(t, "returned", conf)
public, _ := testhelpers.NewKratosServerWithCSRF(t, reg)
require.NoError(t, reg.IdentityManager().Create(context.Background(), identityToVerify,
identity.ManagerAllowWriteProtectedTraits))
expect := func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values), c int) string {
if hc == nil {
hc = testhelpers.NewDebugClient(t)
if !isAPI {
hc = testhelpers.NewClientWithCookies(t)
hc.Transport = testhelpers.NewTransportWithLogger(testhelpers.NewTestTransport(t), t).RoundTripper
}
}
return testhelpers.SubmitVerificationForm(t, isAPI, isSPA, hc, public, values, c,
testhelpers.ExpectURL(isAPI || isSPA, public.URL+verification.RouteSubmitFlow, conf.SelfServiceFlowVerificationUI(ctx).String()))
}
expectValidationError := func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string {
return expect(t, hc, isAPI, isSPA, values, testhelpers.ExpectStatusCode(isAPI || isSPA, http.StatusBadRequest, http.StatusOK))
}
expectSuccess := func(t *testing.T, hc *http.Client, isAPI, isSPA bool, values func(url.Values)) string {
return expect(t, hc, isAPI, isSPA, values, http.StatusOK)
}
t.Run("description=should set all the correct verification payloads after submission", func(t *testing.T) {
body := expectSuccess(t, nil, false, false, func(v url.Values) {
v.Set("email", "test@ory.sh")
})
testhelpers.SnapshotTExcept(t, json.RawMessage(gjson.Get(body, "ui.nodes").String()), []string{"0.attributes.value"})
})
t.Run("description=should set all the correct verification payloads", func(t *testing.T) {
c := testhelpers.NewClientWithCookies(t)
rs := testhelpers.GetVerificationFlow(t, c, public)
testhelpers.SnapshotTExcept(t, rs.Ui.Nodes, []string{"0.attributes.value"})
assert.EqualValues(t, public.URL+verification.RouteSubmitFlow+"?flow="+rs.Id, rs.Ui.Action)
assert.Empty(t, rs.Ui.Messages)
})
t.Run("description=should not execute submit without correct method set", func(t *testing.T) {
c := testhelpers.NewClientWithCookies(t)
rs := testhelpers.GetVerificationFlow(t, c, public)
res, err := c.PostForm(rs.Ui.Action, url.Values{"method": {"not-link"}, "email": {verificationEmail}})
require.NoError(t, err)
assert.EqualValues(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowVerificationUI(ctx).String())
body := ioutilx.MustReadAll(res.Body)
require.NoError(t, res.Body.Close())
assert.Equal(t, "Could not find a strategy to verify your account with. Did you fill out the form correctly?", gjson.GetBytes(body, "ui.messages.0.text").String(), "%s", body)
})
t.Run("description=should require an email to be sent", func(t *testing.T) {
check := func(t *testing.T, actual string) {
assert.EqualValues(t, string(node.LinkGroup), gjson.Get(actual, "active").String(), "%s", actual)
assert.EqualValues(t, "Property email is missing.",
gjson.Get(actual, "ui.nodes.#(attributes.name==email).messages.0.text").String(),
"%s", actual)
}
values := func(v url.Values) {
v.Del("email")
}
t.Run("type=browser", func(t *testing.T) {
check(t, expectValidationError(t, nil, false, false, values))
})
t.Run("type=spa", func(t *testing.T) {
check(t, expectValidationError(t, nil, false, true, values))
})
t.Run("type=api", func(t *testing.T) {
check(t, expectValidationError(t, nil, true, false, values))
})
})
t.Run("description=should require a valid email to be sent", func(t *testing.T) {
check := func(t *testing.T, actual string, value string) {
assert.EqualValues(t, string(node.LinkGroup), gjson.Get(actual, "active").String(), "%s", actual)
assert.EqualValues(t, "Enter a valid email address",
gjson.Get(actual, "ui.nodes.#(attributes.name==email).messages.0.text").String(),
"%s", actual)
}
for _, email := range []string{"\\", "asdf", "...", "aiacobelli.sec@gmail.com,alejandro.iacobelli@mercadolibre.com"} {
values := func(v url.Values) {
v.Set("email", email)
}
t.Run("type=browser", func(t *testing.T) {
check(t, expectValidationError(t, nil, false, false, values), email)
})
t.Run("type=spa", func(t *testing.T) {
check(t, expectValidationError(t, nil, false, true, values), email)
})
t.Run("type=api", func(t *testing.T) {
check(t, expectValidationError(t, nil, true, false, values), email)
})
}
})
t.Run("description=should try to verify an email that does not exist", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationNotifyUnknownRecipients, true)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationNotifyUnknownRecipients, false)
})
var email string
check := func(t *testing.T, actual string) {
assert.EqualValues(t, string(node.LinkGroup), gjson.Get(actual, "active").String(), "%s", actual)
assert.EqualValues(t, email, gjson.Get(actual, "ui.nodes.#(attributes.name==email).attributes.value").String(), "%s", actual)
assertx.EqualAsJSON(t, text.NewVerificationEmailSent(), json.RawMessage(gjson.Get(actual, "ui.messages.0").Raw))
message := testhelpers.CourierExpectMessage(ctx, t, reg, email, "Someone tried to verify this email address")
assert.Contains(t, message.Body, "If this was you, check if you signed up using a different address.")
}
values := func(v url.Values) {
v.Set("email", email)
}
t.Run("type=browser", func(t *testing.T) {
email = x.NewUUID().String() + "@ory.sh"
check(t, expectSuccess(t, nil, false, false, values))
})
t.Run("type=spa", func(t *testing.T) {
email = x.NewUUID().String() + "@ory.sh"
check(t, expectSuccess(t, nil, false, true, values))
})
t.Run("type=api", func(t *testing.T) {
email = x.NewUUID().String() + "@ory.sh"
check(t, expectSuccess(t, nil, true, false, values))
})
})
t.Run("description=should not be able to use an invalid link", func(t *testing.T) {
c := testhelpers.NewClientWithCookies(t)
f := testhelpers.InitializeVerificationFlowViaBrowser(t, c, false, public)
res, err := c.Get(public.URL + verification.RouteSubmitFlow + "?flow=" + f.Id + "&token=i-do-not-exist")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowVerificationUI(ctx).String()+"?flow=")
sr, _, err := testhelpers.NewSDKCustomClient(public, c).FrontendAPI.GetVerificationFlow(context.Background()).Id(res.Request.URL.Query().Get("flow")).Execute()
require.NoError(t, err)
require.Len(t, sr.Ui.Messages, 1)
assert.Equal(t, "The verification token is invalid or has already been used. Please retry the flow.", sr.Ui.Messages[0].Text)
})
t.Run("description=should not be able to request link with an outdated flow", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationRequestLifespan, time.Millisecond*200)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationRequestLifespan, time.Minute)
})
c := testhelpers.NewClientWithCookies(t)
rs := testhelpers.GetVerificationFlow(t, c, public)
time.Sleep(time.Millisecond * 201)
res, err := c.PostForm(rs.Ui.Action, url.Values{"method": {"link"}, "email": {verificationEmail}})
require.NoError(t, err)
assert.EqualValues(t, http.StatusOK, res.StatusCode)
assert.NotContains(t, res.Request.URL.String(), "flow="+rs.Id)
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowVerificationUI(ctx).String())
})
t.Run("description=should not be able to use link with an outdated flow", func(t *testing.T) {
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationRequestLifespan, time.Millisecond*200)
t.Cleanup(func() {
conf.MustSet(ctx, config.ViperKeySelfServiceVerificationRequestLifespan, time.Minute)
})
c := testhelpers.NewClientWithCookies(t)
body := expectSuccess(t, c, false, false, func(v url.Values) {
v.Set("email", verificationEmail)
})
message := testhelpers.CourierExpectMessage(ctx, t, reg, verificationEmail, "Please verify your email address")
assert.Contains(t, message.Body, "Verify your account by opening the following link")
verificationLink := testhelpers.CourierExpectLinkInMessage(t, message, 1)
time.Sleep(time.Millisecond * 201)
// Clear cookies as link might be opened in another browser
c = testhelpers.NewClientWithCookies(t)
res, err := c.Get(verificationLink)
require.NoError(t, err)
assert.EqualValues(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowVerificationUI(ctx).String())
assert.NotContains(t, res.Request.URL.String(), gjson.Get(body, "id").String())
sr, _, err := testhelpers.NewSDKCustomClient(public, c).FrontendAPI.GetVerificationFlow(context.Background()).Id(res.Request.URL.Query().Get("flow")).Execute()
require.NoError(t, err)
require.Len(t, sr.Ui.Messages, 1)
assert.Contains(t, sr.Ui.Messages[0].Text, "The verification flow expired")
})
t.Run("description=should verify an email address", func(t *testing.T) {
var wg sync.WaitGroup
testhelpers.NewVerifyAfterHookWebHookTarget(ctx, t, conf, func(t *testing.T, msg []byte) {
defer wg.Done()
assert.EqualValues(t, true, gjson.GetBytes(msg, "identity.verifiable_addresses.0.verified").Bool(), string(msg))
assert.EqualValues(t, "completed", gjson.GetBytes(msg, "identity.verifiable_addresses.0.status").String(), string(msg))
})
check := func(t *testing.T, actual string) {
assert.EqualValues(t, string(node.LinkGroup), gjson.Get(actual, "active").String(), "%s", actual)
assert.EqualValues(t, verificationEmail, gjson.Get(actual, "ui.nodes.#(attributes.name==email).attributes.value").String(), "%s", actual)
assertx.EqualAsJSON(t, text.NewVerificationEmailSent(), json.RawMessage(gjson.Get(actual, "ui.messages.0").Raw))
message := testhelpers.CourierExpectMessage(ctx, t, reg, verificationEmail, "Please verify your email address")
assert.Contains(t, message.Body, "Verify your account by opening the following link")
verificationLink := testhelpers.CourierExpectLinkInMessage(t, message, 1)
assert.Contains(t, verificationLink, public.URL+verification.RouteSubmitFlow)
assert.Contains(t, verificationLink, "token=")
cl := testhelpers.NewClientWithCookies(t)
res, err := cl.Get(verificationLink)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowVerificationUI(ctx).String())
body := string(ioutilx.MustReadAll(res.Body))
assert.EqualValues(t, "passed_challenge", gjson.Get(body, "state").String())
assert.EqualValues(t, text.NewInfoSelfServiceVerificationSuccessful().Text, gjson.Get(body, "ui.messages.0.text").String())
id, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(context.Background(), identityToVerify.ID)
require.NoError(t, err)
require.Len(t, id.VerifiableAddresses, 1)
address := id.VerifiableAddresses[0]
assert.EqualValues(t, verificationEmail, address.Value)
assert.True(t, address.Verified)
assert.EqualValues(t, identity.VerifiableAddressStatusCompleted, address.Status)
assert.True(t, time.Time(*address.VerifiedAt).Add(time.Second*5).After(time.Now()))
}
values := func(v url.Values) {
v.Set("email", verificationEmail)
}
t.Run("type=browser", func(t *testing.T) {
wg.Add(1)
check(t, expectSuccess(t, nil, false, false, values))
wg.Wait()
})
t.Run("type=spa", func(t *testing.T) {
wg.Add(1)
check(t, expectSuccess(t, nil, false, true, values))
wg.Wait()
})
t.Run("type=api", func(t *testing.T) {
wg.Add(1)
check(t, expectSuccess(t, nil, true, false, values))
wg.Wait()
})
})
t.Run("description=should verify an email address when the link is opened in another browser", func(t *testing.T) {
check := func(t *testing.T, actual string) {
message := testhelpers.CourierExpectMessage(ctx, t, reg, verificationEmail, "Please verify your email address")
verificationLink := testhelpers.CourierExpectLinkInMessage(t, message, 1)
cl := testhelpers.NewClientWithCookies(t)
res, err := cl.Get(verificationLink)
require.NoError(t, err)
body := string(ioutilx.MustReadAll(res.Body))
require.NoError(t, res.Body.Close())
require.Len(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL)), 1)
assert.Contains(t, cl.Jar.Cookies(urlx.ParseOrPanic(public.URL))[0].Name, nosurfx.CSRFTokenName)
actualRes, err := cl.Get(public.URL + verification.RouteGetFlow + "?id=" + gjson.Get(body, "id").String())
require.NoError(t, err)
actualBody := string(ioutilx.MustReadAll(actualRes.Body))
require.NoError(t, actualRes.Body.Close())
assert.Equal(t, http.StatusOK, actualRes.StatusCode)
assertx.EqualAsJSON(t, body, actualBody)
assert.EqualValues(t, "passed_challenge", gjson.Get(actualBody, "state").String())
}
values := func(v url.Values) {
v.Set("email", verificationEmail)
}
check(t, expectSuccess(t, nil, false, false, values))
})
newValidFlow := func(t *testing.T, fType flow.Type, requestURL string) (*verification.Flow, *link.VerificationToken) {
f, err := verification.NewFlow(reg, time.Hour, nosurfx.FakeCSRFToken, httptest.NewRequest("GET", requestURL, nil), nil, fType)
require.NoError(t, err)
f.State = flow.StateEmailSent
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(context.Background(), f))
email := identity.NewVerifiableEmailAddress(verificationEmail, identityToVerify.ID)
identityToVerify.VerifiableAddresses = append(identityToVerify.VerifiableAddresses, *email)
require.NoError(t, reg.IdentityManager().Update(context.Background(), identityToVerify, identity.ManagerAllowWriteProtectedTraits))
token := link.NewSelfServiceVerificationToken(&identityToVerify.VerifiableAddresses[0], f, time.Hour)
require.NoError(t, reg.VerificationTokenPersister().CreateVerificationToken(context.Background(), token))
return f, token
}
newValidBrowserFlow := func(t *testing.T, requestURL string) (*verification.Flow, *link.VerificationToken) {
return newValidFlow(t, flow.TypeBrowser, requestURL)
}
t.Run("case=respects return_to URI parameter", func(t *testing.T) {
returnToURL := public.URL + "/after-verification"
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnToURL})
for _, fType := range []flow.Type{flow.TypeBrowser, flow.TypeAPI} {
t.Run(fmt.Sprintf("type=%s", fType), func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
flow, token := newValidFlow(t, fType, public.URL+verification.RouteInitBrowserFlow+"?"+url.Values{"return_to": {returnToURL}}.Encode())
res, err := client.Get(public.URL + verification.RouteSubmitFlow + "?" + url.Values{"flow": {flow.ID.String()}, "token": {token.Token}}.Encode())
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
responseBody := gjson.ParseBytes(ioutilx.MustReadAll(res.Body))
assert.Equal(t, responseBody.Get("state").String(), "passed_challenge", "%v", responseBody)
assert.True(t, responseBody.Get("ui.nodes.#(attributes.id==continue)").Exists(), "%v", responseBody)
assert.Equal(t, returnToURL, responseBody.Get("ui.nodes.#(attributes.id==continue).attributes.href").String(), "%v", responseBody)
})
}
})
t.Run("case=contains default return to url", func(t *testing.T) {
globalReturnTo := public.URL + "/global"
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, globalReturnTo)
for _, fType := range []flow.Type{flow.TypeBrowser, flow.TypeAPI} {
t.Run(fmt.Sprintf("type=%s", fType), func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
flow, token := newValidFlow(t, fType, public.URL+verification.RouteInitBrowserFlow)
res, err := client.Get(public.URL + verification.RouteSubmitFlow + "?" + url.Values{"flow": {flow.ID.String()}, "token": {token.Token}}.Encode())
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
responseBody := gjson.ParseBytes(ioutilx.MustReadAll(res.Body))
assert.Equal(t, responseBody.Get("state").String(), "passed_challenge", "%v", responseBody)
assert.True(t, responseBody.Get("ui.nodes.#(attributes.id==continue)").Exists(), "%v", responseBody)
assert.Equal(t, globalReturnTo, responseBody.Get("ui.nodes.#(attributes.id==continue).attributes.href").String(), "%v", responseBody)
})
}
})
t.Run("case=should not be able to use code from different flow", func(t *testing.T) {
f1, _ := newValidBrowserFlow(t, public.URL+verification.RouteInitBrowserFlow)
_, t2 := newValidBrowserFlow(t, public.URL+verification.RouteInitBrowserFlow)
formValues := url.Values{
"flow": {f1.ID.String()},
"token": {t2.Token},
}
submitUrl := public.URL + verification.RouteSubmitFlow + "?" + formValues.Encode()
res, err := public.Client().Get(submitUrl)
require.NoError(t, err)
body := ioutilx.MustReadAll(res.Body)
assert.Equal(t, "The verification token is invalid or has already been used. Please retry the flow.", gjson.GetBytes(body, "ui.messages.0.text").String())
})
t.Run("description=should apply pending traits change when token is redeemed", func(t *testing.T) {
// Sibling subtests "fires settings post-persist webhook..." and
// "post-persist webhook error..." mutate the shared
// selfservice.flows.settings.after.profile.hooks config via
// conf.MustSet and clean up on subtest exit. Running this subtest
// in parallel races against that cleanup and against the shared
// SQLite database (SQLITE_BUSY), so it stays sequential.
// Create an identity with original traits.
pendingID := &identity.Identity{
ID: x.NewUUID(),
State: identity.StateActive,
Traits: identity.Traits(`{"email":"original@ory.sh"}`),
SchemaID: config.DefaultIdentityTraitsSchemaID,
Credentials: map[identity.CredentialsType]identity.Credentials{
"password": {Type: "password", Identifiers: []string{"original@ory.sh"}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"foo"}`)},
},
}
require.NoError(t, reg.IdentityManager().Create(ctx, pendingID, identity.ManagerAllowWriteProtectedTraits))
// The apply step now requires a live session that created the change.
sess, err := testhelpers.NewActiveSession(httptest.NewRequest("GET", "/", nil), reg, pendingID,
time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
// Create a verification flow in StateEmailSent.
f, err := verification.NewFlow(reg, time.Hour, nosurfx.FakeCSRFToken, httptest.NewRequest("GET", public.URL+verification.RouteInitBrowserFlow, nil), nil, flow.TypeBrowser)
require.NoError(t, err)
f.State = flow.StateEmailSent
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f))
// Create a settings flow that acts as the origin for the PTC.
sf := &settings.Flow{
ID: x.NewUUID(),
ExpiresAt: time.Now().UTC().Add(time.Hour),
IssuedAt: time.Now().UTC(),
RequestURL: public.URL + "/settings",
IdentityID: pendingID.ID,
Identity: pendingID,
Type: flow.TypeBrowser,
State: flow.StateShowForm,
InternalContext: []byte("{}"),
}
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, sf))
// Create a PendingTraitsChange record linked to this flow.
sessID := sess.ID
originFlowID := sf.ID
ptc := &identity.PendingTraitsChange{
ID: x.NewUUID(),
IdentityID: pendingID.ID,
SessionID: uuid.NullUUID{UUID: sessID, Valid: true},
OriginSettingsFlowID: uuid.NullUUID{UUID: originFlowID, Valid: true},
NewAddressValue: "changed@ory.sh",
NewAddressVia: string(identity.AddressTypeEmail),
OriginalTraitsHash: identity.HashTraits(json.RawMessage(pendingID.Traits)),
ProposedTraits: json.RawMessage(`{"email":"changed@ory.sh"}`),
VerificationFlowID: f.ID,
Status: identity.PendingTraitsChangeStatusPending,
}
require.NoError(t, reg.PendingTraitsChangePersister().CreatePendingTraitsChange(ctx, ptc))
// Create a verification token with a nil address ID (pending-change signal).
// The address is only used to construct the token; nil it before persisting
// so pop does not try to create a non-existent association row.
token := link.NewSelfServiceVerificationToken(ptc, f, time.Hour)
token.VerifiableAddress = nil
require.NoError(t, reg.VerificationTokenPersister().CreateVerificationToken(ctx, token))
// Redeem the token via HTTP GET.
cl := testhelpers.NewClientWithCookies(t)
res, err := cl.Get(public.URL + verification.RouteSubmitFlow + "?" + url.Values{"flow": {f.ID.String()}, "token": {token.Token}}.Encode())
require.NoError(t, err)
body := string(ioutilx.MustReadAll(res.Body))
require.NoError(t, res.Body.Close())
require.Equal(t, http.StatusOK, res.StatusCode)
require.EqualValues(t, "passed_challenge", gjson.Get(body, "state").String(), "%s", body)
assert.EqualValues(t, text.NewInfoSelfServiceVerificationSuccessful().Text, gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
// Verify the identity traits were updated.
updated, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(ctx, pendingID.ID)
require.NoError(t, err)
assert.EqualValues(t, "changed@ory.sh", gjson.GetBytes([]byte(updated.Traits), "email").String())
// Verify the new address is marked as verified.
var foundAddress *identity.VerifiableAddress
for idx := range updated.VerifiableAddresses {
if updated.VerifiableAddresses[idx].Value == "changed@ory.sh" {
foundAddress = &updated.VerifiableAddresses[idx]
break
}
}
require.NotNil(t, foundAddress, "new address should exist on identity")
assert.True(t, foundAddress.Verified)
assert.EqualValues(t, identity.VerifiableAddressStatusCompleted, foundAddress.Status)
})
t.Run("description=fires settings post-persist webhook after pending traits change is applied", func(t *testing.T) {
// Set up a recording webhook target.
var receivedBody []byte
receivedCh := make(chan struct{}, 1)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedBody, _ = io.ReadAll(r.Body)
select {
case receivedCh <- struct{}{}:
default:
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(ts.Close)
// Register webhook as a settings-profile post-persist hook.
conf.MustSet(ctx, "selfservice.flows.settings.after.profile.hooks", []map[string]any{
{
"hook": "web_hook",
"config": map[string]any{
"url": ts.URL,
"method": "POST",
"body": "base64://" + base64.StdEncoding.EncodeToString([]byte(`function(ctx) ctx`)),
},
},
})
t.Cleanup(func() {
conf.MustSet(ctx, "selfservice.flows.settings.after.profile.hooks", []map[string]any{})
})
// Create an identity + active session.
ident := &identity.Identity{
ID: x.NewUUID(),
Traits: identity.Traits(`{"email":"posthook-link-original@ory.sh"}`),
SchemaID: config.DefaultIdentityTraitsSchemaID,
State: identity.StateActive,
}
require.NoError(t, reg.IdentityManager().Create(ctx, ident, identity.ManagerAllowWriteProtectedTraits))
sess, err := testhelpers.NewActiveSession(httptest.NewRequest("GET", "/", nil), reg, ident, time.Now().UTC(),
identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
// Create verification flow + PTC linked to the session.
f, err := verification.NewFlow(reg, time.Hour, nosurfx.FakeCSRFToken,
httptest.NewRequest("GET", public.URL+verification.RouteInitBrowserFlow, nil), nil, flow.TypeBrowser)
require.NoError(t, err)
f.State = flow.StateEmailSent
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f))
// Create a settings flow that acts as the origin for the PTC.
sfPosthook := &settings.Flow{
ID: x.NewUUID(),
ExpiresAt: time.Now().UTC().Add(time.Hour),
IssuedAt: time.Now().UTC(),
RequestURL: public.URL + "/settings",
IdentityID: ident.ID,
Identity: ident,
Type: flow.TypeBrowser,
State: flow.StateShowForm,
InternalContext: []byte("{}"),
}
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, sfPosthook))
sessID := sess.ID
originFlowIDPosthook := sfPosthook.ID
ptc := &identity.PendingTraitsChange{
ID: x.NewUUID(),
IdentityID: ident.ID,
SessionID: uuid.NullUUID{UUID: sessID, Valid: true},
OriginSettingsFlowID: uuid.NullUUID{UUID: originFlowIDPosthook, Valid: true},
NewAddressValue: "posthook-link-new@ory.sh",
NewAddressVia: string(identity.AddressTypeEmail),
OriginalTraitsHash: identity.HashTraits(json.RawMessage(ident.Traits)),
ProposedTraits: json.RawMessage(`{"email":"posthook-link-new@ory.sh"}`),
VerificationFlowID: f.ID,
Status: identity.PendingTraitsChangeStatusPending,
}
require.NoError(t, reg.PendingTraitsChangePersister().CreatePendingTraitsChange(ctx, ptc))
// Create a verification token with a nil address ID (pending-change signal).
token := link.NewSelfServiceVerificationToken(ptc, f, time.Hour)
token.VerifiableAddress = nil
require.NoError(t, reg.VerificationTokenPersister().CreateVerificationToken(ctx, token))
// Redeem the token via HTTP GET (the link-based verification path).
cl := testhelpers.NewClientWithCookies(t)
res, err := cl.Get(public.URL + verification.RouteSubmitFlow + "?" + url.Values{"flow": {f.ID.String()}, "token": {token.Token}}.Encode())
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.Equal(t, http.StatusOK, res.StatusCode)
select {
case <-receivedCh:
case <-time.After(5 * time.Second):
t.Fatal("settings post-persist webhook was not invoked after pending traits change applied")
}
assert.Contains(t, string(receivedBody), "posthook-link-new@ory.sh")
})
t.Run("description=post-persist webhook error after pending traits change is surfaced", func(t *testing.T) {
// Webhook target that always returns 500.
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"boom"}`))
}))
t.Cleanup(ts.Close)
conf.MustSet(ctx, "selfservice.flows.settings.after.profile.hooks", []map[string]any{
{
"hook": "web_hook",
"config": map[string]any{
"url": ts.URL,
"method": "POST",
"body": "base64://" + base64.StdEncoding.EncodeToString([]byte(`function(ctx) ctx`)),
},
},
})
t.Cleanup(func() {
conf.MustSet(ctx, "selfservice.flows.settings.after.profile.hooks", []map[string]any{})
})
ident := &identity.Identity{
ID: x.NewUUID(),
Traits: identity.Traits(`{"email":"posthook-link-err-original@ory.sh"}`),
SchemaID: config.DefaultIdentityTraitsSchemaID,
State: identity.StateActive,
}
require.NoError(t, reg.IdentityManager().Create(ctx, ident, identity.ManagerAllowWriteProtectedTraits))
sess, err := testhelpers.NewActiveSession(httptest.NewRequest("GET", "/", nil), reg, ident, time.Now().UTC(),
identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
f, err := verification.NewFlow(reg, time.Hour, nosurfx.FakeCSRFToken,
httptest.NewRequest("GET", public.URL+verification.RouteInitBrowserFlow, nil), nil, flow.TypeBrowser)
require.NoError(t, err)
f.State = flow.StateEmailSent
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f))
// Create a settings flow that acts as the origin for the PTC.
sfErr := &settings.Flow{
ID: x.NewUUID(),
ExpiresAt: time.Now().UTC().Add(time.Hour),
IssuedAt: time.Now().UTC(),
RequestURL: public.URL + "/settings",
IdentityID: ident.ID,
Identity: ident,
Type: flow.TypeBrowser,
State: flow.StateShowForm,
InternalContext: []byte("{}"),
}
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, sfErr))
sessID := sess.ID
originFlowIDErr := sfErr.ID
ptc := &identity.PendingTraitsChange{
ID: x.NewUUID(),
IdentityID: ident.ID,
SessionID: uuid.NullUUID{UUID: sessID, Valid: true},
OriginSettingsFlowID: uuid.NullUUID{UUID: originFlowIDErr, Valid: true},
NewAddressValue: "posthook-link-err-new@ory.sh",
NewAddressVia: string(identity.AddressTypeEmail),
OriginalTraitsHash: identity.HashTraits(json.RawMessage(ident.Traits)),
ProposedTraits: json.RawMessage(`{"email":"posthook-link-err-new@ory.sh"}`),
VerificationFlowID: f.ID,
Status: identity.PendingTraitsChangeStatusPending,
}
require.NoError(t, reg.PendingTraitsChangePersister().CreatePendingTraitsChange(ctx, ptc))
// Create a verification token with a nil address ID (pending-change signal).
token := link.NewSelfServiceVerificationToken(ptc, f, time.Hour)
token.VerifiableAddress = nil
require.NoError(t, reg.VerificationTokenPersister().CreateVerificationToken(ctx, token))
// Redeem the token via HTTP GET (the link-based verification path).
cl := testhelpers.NewClientWithCookies(t)
res, err := cl.Get(public.URL + verification.RouteSubmitFlow + "?" + url.Values{"flow": {f.ID.String()}, "token": {token.Token}}.Encode())
require.NoError(t, err)
require.NoError(t, res.Body.Close())
// Apply already committed before the webhook fired: the new traits and
// the verified address persist even though the hook failed.
updated, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(ctx, ident.ID)
require.NoError(t, err)
assert.EqualValues(t, "posthook-link-err-new@ory.sh", gjson.GetBytes([]byte(updated.Traits), "email").String(),
"traits are committed before the post-persist hook runs")
var foundAddress *identity.VerifiableAddress
for idx := range updated.VerifiableAddresses {
if updated.VerifiableAddresses[idx].Value == "posthook-link-err-new@ory.sh" {
foundAddress = &updated.VerifiableAddresses[idx]
break
}
}
require.NotNil(t, foundAddress, "new address should exist on identity")
assert.True(t, foundAddress.Verified, "new address should be verified even though the webhook failed")
assert.EqualValues(t, identity.VerifiableAddressStatusCompleted, foundAddress.Status)
})
t.Run("description=should reject pending traits change on concurrent modification", func(t *testing.T) {
// See the note on "should apply pending traits change when token
// is redeemed" — this subtest shares config and SQLite state with
// the webhook-mutating siblings above and must stay sequential.
// Create an identity with original traits.
concurrentID := &identity.Identity{
ID: x.NewUUID(),
State: identity.StateActive,
Traits: identity.Traits(`{"email":"original2@ory.sh"}`),
SchemaID: config.DefaultIdentityTraitsSchemaID,
Credentials: map[identity.CredentialsType]identity.Credentials{
"password": {Type: "password", Identifiers: []string{"original2@ory.sh"}, Config: sqlxx.JSONRawMessage(`{"hashed_password":"foo"}`)},
},
}
require.NoError(t, reg.IdentityManager().Create(ctx, concurrentID, identity.ManagerAllowWriteProtectedTraits))
// A live session is required so we reach the traits-hash check rather
// than bailing at the SessionID-nil guard first.
sess, err := testhelpers.NewActiveSession(httptest.NewRequest("GET", "/", nil), reg, concurrentID,
time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
// Compute traits hash from the original traits.
originalHash := identity.HashTraits(json.RawMessage(concurrentID.Traits))
// Simulate a concurrent modification: update the identity's traits directly.
concurrentID.Traits = identity.Traits(`{"email":"concurrent@ory.sh"}`)
require.NoError(t, reg.IdentityManager().Update(ctx, concurrentID, identity.ManagerAllowWriteProtectedTraits))
// Create a verification flow in StateEmailSent.
f, err := verification.NewFlow(reg, time.Hour, nosurfx.FakeCSRFToken, httptest.NewRequest("GET", public.URL+verification.RouteInitBrowserFlow, nil), nil, flow.TypeBrowser)
require.NoError(t, err)
f.State = flow.StateEmailSent
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f))
// Create a settings flow that acts as the origin for the PTC.
sfConcurrent := &settings.Flow{
ID: x.NewUUID(),
ExpiresAt: time.Now().UTC().Add(time.Hour),
IssuedAt: time.Now().UTC(),
RequestURL: public.URL + "/settings",
IdentityID: concurrentID.ID,
Identity: concurrentID,
Type: flow.TypeBrowser,
State: flow.StateShowForm,
InternalContext: []byte("{}"),
}
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, sfConcurrent))
// Create a PendingTraitsChange with the OLD traits hash (before concurrent modification).
sessID := sess.ID
originFlowIDConcurrent := sfConcurrent.ID
ptc := &identity.PendingTraitsChange{
ID: x.NewUUID(),
IdentityID: concurrentID.ID,
SessionID: uuid.NullUUID{UUID: sessID, Valid: true},
OriginSettingsFlowID: uuid.NullUUID{UUID: originFlowIDConcurrent, Valid: true},
NewAddressValue: "changed2@ory.sh",
NewAddressVia: string(identity.AddressTypeEmail),
OriginalTraitsHash: originalHash,
ProposedTraits: json.RawMessage(`{"email":"changed2@ory.sh"}`),
VerificationFlowID: f.ID,
Status: identity.PendingTraitsChangeStatusPending,
}
require.NoError(t, reg.PendingTraitsChangePersister().CreatePendingTraitsChange(ctx, ptc))
// Create a verification token with a nil address ID.
token := link.NewSelfServiceVerificationToken(ptc, f, time.Hour)
token.VerifiableAddress = nil
require.NoError(t, reg.VerificationTokenPersister().CreateVerificationToken(ctx, token))
// Redeem via GET (the link-based path).
cl := testhelpers.NewClientWithCookies(t)
res, err := cl.Get(public.URL + verification.RouteSubmitFlow + "?" + url.Values{"flow": {f.ID.String()}, "token": {token.Token}}.Encode())
require.NoError(t, err)
body := string(ioutilx.MustReadAll(res.Body))
require.NoError(t, res.Body.Close())
// Should show the "token invalid" error (concurrent modification detected).
assert.Contains(t, body, "The verification token is invalid or has already been used")
// Identity traits should NOT have been updated.
unchanged, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(ctx, concurrentID.ID)
require.NoError(t, err)
assert.EqualValues(t, "concurrent@ory.sh", gjson.GetBytes([]byte(unchanged.Traits), "email").String(),
"traits should remain at the concurrent value, not the proposed value")
})
t.Run("description=should reject pending traits change when session is revoked", func(t *testing.T) {
ident := &identity.Identity{
ID: x.NewUUID(),
State: identity.StateActive,
Traits: identity.Traits(`{"email":"revoke-link-original@ory.sh"}`),
SchemaID: config.DefaultIdentityTraitsSchemaID,
}
require.NoError(t, reg.IdentityManager().Create(ctx, ident, identity.ManagerAllowWriteProtectedTraits))
sess, err := testhelpers.NewActiveSession(httptest.NewRequest("GET", "/", nil), reg, ident,
time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
f, err := verification.NewFlow(reg, time.Hour, nosurfx.FakeCSRFToken,
httptest.NewRequest("GET", public.URL+verification.RouteInitBrowserFlow, nil), nil, flow.TypeBrowser)
require.NoError(t, err)
f.State = flow.StateEmailSent
require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f))
// Create a settings flow that acts as the origin for the PTC.
sfRevoke := &settings.Flow{
ID: x.NewUUID(),
ExpiresAt: time.Now().UTC().Add(time.Hour),
IssuedAt: time.Now().UTC(),
RequestURL: public.URL + "/settings",
IdentityID: ident.ID,
Identity: ident,
Type: flow.TypeBrowser,
State: flow.StateShowForm,
InternalContext: []byte("{}"),
}
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, sfRevoke))
sessID := sess.ID
originFlowIDRevoke := sfRevoke.ID
ptc := &identity.PendingTraitsChange{
ID: x.NewUUID(),
IdentityID: ident.ID,
SessionID: uuid.NullUUID{UUID: sessID, Valid: true},
OriginSettingsFlowID: uuid.NullUUID{UUID: originFlowIDRevoke, Valid: true},
NewAddressValue: "revoke-link-new@ory.sh",
NewAddressVia: string(identity.AddressTypeEmail),
OriginalTraitsHash: identity.HashTraits(json.RawMessage(ident.Traits)),
ProposedTraits: json.RawMessage(`{"email":"revoke-link-new@ory.sh"}`),
VerificationFlowID: f.ID,
Status: identity.PendingTraitsChangeStatusPending,
}
require.NoError(t, reg.PendingTraitsChangePersister().CreatePendingTraitsChange(ctx, ptc))
token := link.NewSelfServiceVerificationToken(ptc, f, time.Hour)
token.VerifiableAddress = nil
require.NoError(t, reg.VerificationTokenPersister().CreateVerificationToken(ctx, token))
// Revoke the session AFTER the PTC is created — simulates logout between flow start and verification.
require.NoError(t, reg.SessionPersister().RevokeSessionById(ctx, sess.ID))
cl := testhelpers.NewClientWithCookies(t)
res, err := cl.Get(public.URL + verification.RouteSubmitFlow + "?" + url.Values{"flow": {f.ID.String()}, "token": {token.Token}}.Encode())
require.NoError(t, err)
body := string(ioutilx.MustReadAll(res.Body))
require.NoError(t, res.Body.Close())
assert.Contains(t, body, "The verification token is invalid or has already been used")
// Traits should NOT be updated.
unchanged, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(ctx, ident.ID)
require.NoError(t, err)
assert.EqualValues(t, "revoke-link-original@ory.sh", gjson.GetBytes([]byte(unchanged.Traits), "email").String())
})
t.Run("case=doesn't continue with OAuth2 flow if code is invalid", func(t *testing.T) {
globalReturnTo := public.URL + "/global"
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, globalReturnTo)
client := testhelpers.NewClientWithCookies(t)
flow, _ := newValidFlow(t, flow.TypeBrowser, public.URL+verification.RouteInitBrowserFlow)
res, err := client.Get(public.URL + verification.RouteSubmitFlow + "?" + url.Values{
"flow": {flow.ID.String()},
"token": {"invalid token"},
}.Encode())
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
responseBody := gjson.ParseBytes(ioutilx.MustReadAll(res.Body))
assert.Equal(t, "choose_method", responseBody.Get("state").String(), "%v", responseBody)
assert.Len(t, responseBody.Get("ui.messages").Array(), 1, "%v", responseBody)
assert.Equal(t, "The verification token is invalid or has already been used. Please retry the flow.", responseBody.Get("ui.messages.0.text").String(), "%v", responseBody)
})
}

View File

@@ -0,0 +1,29 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
},
"verification": {
"via": "email"
},
"recovery": {
"via": "email"
}
}
}
}
}
}
}

View File

@@ -0,0 +1,248 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/go-faker/faker/v4"
"github.com/gofrs/uuid"
"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/persistence"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/selfservice/flow/verification"
"github.com/ory/kratos/selfservice/strategy/link"
"github.com/ory/kratos/x"
"github.com/ory/x/assertx"
"github.com/ory/x/contextx"
"github.com/ory/x/sqlcon"
)
func TestPersister(ctx context.Context, p interface {
persistence.Persister
},
) func(t *testing.T) {
return func(t *testing.T) {
nid, p := testhelpers.NewNetworkUnlessExisting(t, ctx, p)
ctx := contextx.WithConfigValue(ctx, config.ViperKeySecretsDefault, []string{"secret-a", "secret-b"})
t.Run("token=recovery", func(t *testing.T) {
newRecoveryToken := func(t *testing.T, email string) (*link.RecoveryToken, *recovery.Flow) {
var req recovery.Flow
require.NoError(t, faker.FakeData(&req))
req.State = flow.StateChooseMethod
require.NoError(t, p.CreateRecoveryFlow(ctx, &req))
var i identity.Identity
require.NoError(t, faker.FakeData(&i))
address := &identity.RecoveryAddress{Value: email, Via: identity.AddressTypeEmail, IdentityID: i.ID}
i.RecoveryAddresses = append(i.RecoveryAddresses, *address)
require.NoError(t, p.CreateIdentity(ctx, &i))
return &link.RecoveryToken{
Token: x.NewUUID().String(),
FlowID: uuid.NullUUID{UUID: req.ID, Valid: true},
RecoveryAddress: &i.RecoveryAddresses[0],
ExpiresAt: time.Now(),
IssuedAt: time.Now(),
IdentityID: i.ID,
TokenType: link.RecoveryTokenTypeAdmin,
}, &req
}
t.Run("case=should error when the recovery token does not exist", func(t *testing.T) {
_, err := p.UseRecoveryToken(ctx, x.NewUUID(), "i-do-not-exist")
require.Error(t, err)
})
t.Run("case=should create a new recovery token", func(t *testing.T) {
token, _ := newRecoveryToken(t, "foo-user@ory.sh")
require.NoError(t, p.CreateRecoveryToken(ctx, token))
})
t.Run("case=should error when token is used with different flow id", func(t *testing.T) {
token, _ := newRecoveryToken(t, "foo-user1@ory.sh")
require.NoError(t, p.CreateRecoveryToken(ctx, token))
_, err := p.UseRecoveryToken(ctx, x.NewUUID(), token.Token)
require.Error(t, err)
})
t.Run("case=should create a recovery token and use it", func(t *testing.T) {
expected, f := newRecoveryToken(t, "other-user@ory.sh")
require.NoError(t, p.CreateRecoveryToken(ctx, expected))
t.Run("not work on another network", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
_, err := p.UseRecoveryToken(ctx, f.ID, expected.Token)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
})
actual, err := p.UseRecoveryToken(ctx, f.ID, expected.Token)
require.NoError(t, err)
assert.Equal(t, nid, actual.NID)
assert.Equal(t, expected.IdentityID, actual.IdentityID)
assert.NotEqual(t, expected.Token, actual.Token)
assert.EqualValues(t, expected.FlowID, actual.FlowID)
t.Run("double spend", func(t *testing.T) {
_, err = p.UseRecoveryToken(ctx, f.ID, expected.Token)
require.Error(t, err)
})
})
t.Run("case=concurrent use of the same token succeeds at most once", func(t *testing.T) {
// HackerOne #3692149 / #3695625: the consume UPDATE must be
// atomic. On READ COMMITTED backends two concurrent submissions
// of the same valid token must not both succeed.
expected, f := newRecoveryToken(t, "concurrent-recovery@ory.sh")
require.NoError(t, p.CreateRecoveryToken(ctx, expected))
const concurrency = 8
var success int32
var start sync.WaitGroup
var done sync.WaitGroup
start.Add(1)
for range concurrency {
done.Add(1)
go func() {
defer done.Done()
start.Wait()
if _, err := p.UseRecoveryToken(ctx, f.ID, expected.Token); err == nil {
atomic.AddInt32(&success, 1)
}
}()
}
start.Done()
done.Wait()
require.EqualValues(t, 1, atomic.LoadInt32(&success), "exactly one concurrent consumer may use the token")
})
t.Run("case=update to identity should not invalidate token", func(t *testing.T) {
expected, f := newRecoveryToken(t, "some-user@ory.sh")
require.NoError(t, p.CreateRecoveryToken(ctx, expected))
id, err := p.GetIdentity(ctx, expected.IdentityID, identity.ExpandDefault)
require.NoError(t, err)
require.NoError(t, p.UpdateIdentity(ctx, id))
actual, err := p.UseRecoveryToken(ctx, f.ID, expected.Token)
require.NoError(t, err)
assert.Equal(t, nid, actual.NID)
assert.Equal(t, expected.IdentityID, actual.IdentityID)
assert.NotEqual(t, expected.Token, actual.Token)
assert.EqualValues(t, expected.FlowID, actual.FlowID)
t.Run("double spend", func(t *testing.T) {
_, err = p.UseRecoveryToken(ctx, f.ID, expected.Token)
require.Error(t, err)
})
})
})
t.Run("token=verification", func(t *testing.T) {
newVerificationToken := func(t *testing.T, email string) (*verification.Flow, *link.VerificationToken) {
var f verification.Flow
require.NoError(t, faker.FakeData(&f))
f.State = flow.StateChooseMethod
require.NoError(t, p.CreateVerificationFlow(ctx, &f))
var i identity.Identity
require.NoError(t, faker.FakeData(&i))
address := &identity.VerifiableAddress{Value: email, Via: identity.AddressTypeEmail}
i.VerifiableAddresses = append(i.VerifiableAddresses, *address)
require.NoError(t, p.CreateIdentity(ctx, &i))
return &f, &link.VerificationToken{
Token: x.NewUUID().String(),
FlowID: f.ID,
VerifiableAddress: &i.VerifiableAddresses[0],
ExpiresAt: time.Now(),
IssuedAt: time.Now(),
}
}
t.Run("case=should error when the verification token does not exist", func(t *testing.T) {
_, err := p.UseVerificationToken(ctx, x.NewUUID(), "i-do-not-exist")
require.Error(t, err)
})
t.Run("case=should error when the verification token does exist but the flow does not", func(t *testing.T) {
_, token := newVerificationToken(t, x.NewUUID().String()+"@ory.sh")
require.NoError(t, p.CreateVerificationToken(ctx, token))
_, err := p.UseVerificationToken(ctx, x.NewUUID(), token.Token)
require.Error(t, err)
})
t.Run("case=should create a new verification token", func(t *testing.T) {
_, token := newVerificationToken(t, "foo-user@ory.sh")
require.NoError(t, p.CreateVerificationToken(ctx, token))
})
t.Run("case=should create a verification token and use it", func(t *testing.T) {
f, expected := newVerificationToken(t, "other-user@ory.sh")
require.NoError(t, p.CreateVerificationToken(ctx, expected))
t.Run("not work on another network", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
_, err := p.UseVerificationToken(ctx, f.ID, expected.Token)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
})
actual, err := p.UseVerificationToken(ctx, f.ID, expected.Token)
require.NoError(t, err)
assertx.EqualAsJSONExcept(t, expected.VerifiableAddress, actual.VerifiableAddress, []string{"created_at", "updated_at"})
assert.Equal(t, nid, actual.NID)
assert.Equal(t, expected.VerifiableAddress.IdentityID, actual.VerifiableAddress.IdentityID)
assert.NotEqual(t, expected.Token, actual.Token)
assert.EqualValues(t, expected.FlowID, actual.FlowID)
_, err = p.UseVerificationToken(ctx, f.ID, expected.Token)
require.Error(t, err)
})
t.Run("case=concurrent use of the same token succeeds at most once", func(t *testing.T) {
// HackerOne #3692149 / #3695625: the consume UPDATE must be
// atomic. On READ COMMITTED backends two concurrent submissions
// of the same valid token must not both succeed.
f, expected := newVerificationToken(t, "concurrent-verification@ory.sh")
require.NoError(t, p.CreateVerificationToken(ctx, expected))
const concurrency = 8
var success int32
var start sync.WaitGroup
var done sync.WaitGroup
start.Add(1)
for range concurrency {
done.Add(1)
go func() {
defer done.Done()
start.Wait()
if _, err := p.UseVerificationToken(ctx, f.ID, expected.Token); err == nil {
atomic.AddInt32(&success, 1)
}
}()
}
start.Done()
done.Wait()
require.EqualValues(t, 1, atomic.LoadInt32(&success), "exactly one concurrent consumer may use the token")
})
})
}
}

View File

@@ -0,0 +1,110 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link
import (
"context"
"time"
"github.com/ory/kratos/selfservice/flow"
"github.com/gofrs/uuid"
errors "github.com/pkg/errors"
"github.com/ory/x/clock"
"github.com/ory/x/randx"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/x"
)
type RecoveryTokenType int
const (
RecoveryTokenTypeAdmin RecoveryTokenType = iota + 1
RecoveryTokenTypeSelfService
)
type RecoveryToken struct {
// ID represents the tokens's unique ID.
//
// required: true
// type: string
// format: uuid
ID uuid.UUID `json:"id" db:"id" faker:"-"`
// Token represents the recovery token. It can not be longer than 64 chars!
Token string `json:"-" db:"token"`
// RecoveryAddress links this token to a recovery address.
// required: true
RecoveryAddress *identity.RecoveryAddress `json:"recovery_address" belongs_to:"identity_recovery_addresses" fk_id:"RecoveryAddressID"`
TokenType RecoveryTokenType `json:"-" faker:"-" db:"token_type"`
// ExpiresAt is the time (UTC) when the token expires.
// required: true
ExpiresAt time.Time `json:"expires_at" faker:"time_type" db:"expires_at"`
// IssuedAt is the time (UTC) when the token was issued.
// required: true
IssuedAt time.Time `json:"issued_at" faker:"time_type" db:"issued_at"`
// 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"`
// RecoveryAddressID is a helper struct field for gobuffalo.pop.
RecoveryAddressID *uuid.UUID `json:"-" faker:"-" db:"identity_recovery_address_id"`
// FlowID is a helper struct field for gobuffalo.pop.
FlowID uuid.NullUUID `json:"-" faker:"-" db:"selfservice_recovery_flow_id"`
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
IdentityID uuid.UUID `json:"identity_id" faker:"-" db:"identity_id"`
}
func (RecoveryToken) TableName(ctx context.Context) string {
return "identity_recovery_tokens"
}
func NewSelfServiceRecoveryToken(address *identity.RecoveryAddress, f *recovery.Flow, expiresIn time.Duration) *RecoveryToken {
now := time.Now().UTC()
var identityID = uuid.UUID{}
var recoveryAddressID = uuid.UUID{}
if address != nil {
identityID = address.IdentityID
recoveryAddressID = address.ID
}
return &RecoveryToken{
ID: x.NewUUID(),
Token: randx.MustString(32, randx.AlphaNum),
RecoveryAddress: address,
ExpiresAt: now.Add(expiresIn),
IssuedAt: now,
IdentityID: identityID,
FlowID: uuid.NullUUID{UUID: f.ID, Valid: true},
RecoveryAddressID: &recoveryAddressID,
TokenType: RecoveryTokenTypeSelfService,
}
}
func NewAdminRecoveryToken(identityID uuid.UUID, fID uuid.UUID, expiresIn time.Duration) *RecoveryToken {
now := time.Now().UTC()
return &RecoveryToken{
ID: x.NewUUID(),
Token: randx.MustString(32, randx.AlphaNum),
ExpiresAt: now.Add(expiresIn),
IssuedAt: now,
IdentityID: identityID,
FlowID: uuid.NullUUID{UUID: fID, Valid: true},
TokenType: RecoveryTokenTypeAdmin,
}
}
func (f *RecoveryToken) Valid(c clock.Clock) error {
if f.ExpiresAt.Before(c.Now()) {
return errors.WithStack(flow.NewFlowExpiredError(c, f.ExpiresAt))
}
return nil
}

View File

@@ -0,0 +1,57 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link_test
import (
"net/http"
"testing"
"time"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/selfservice/strategy/link"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/x/clock"
"github.com/ory/x/stringslice"
"github.com/ory/x/urlx"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/recovery"
)
func TestRecoveryToken(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
req := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.com/")}
t.Run("func=NewSelfServiceRecoveryToken", func(t *testing.T) {
t.Run("case=creates unique tokens", func(t *testing.T) {
f, err := recovery.NewFlow(reg, time.Hour, "", req, nil, flow.TypeBrowser)
require.NoError(t, err)
tokens := make([]string, 10)
for k := range tokens {
tokens[k] = link.NewSelfServiceRecoveryToken(nil, f, time.Hour).Token
}
assert.Len(t, stringslice.Unique(tokens), len(tokens))
})
})
t.Run("method=Valid", func(t *testing.T) {
t.Run("case=is invalid when the flow is expired", func(t *testing.T) {
f, err := recovery.NewFlow(reg, -time.Hour, "", req, nil, flow.TypeBrowser)
require.NoError(t, err)
token := link.NewSelfServiceRecoveryToken(nil, f, -time.Hour)
require.Error(t, token.Valid(clock.New()))
assert.EqualError(t, token.Valid(clock.New()), f.Valid(clock.New()).Error())
})
})
}
func TestRecoveryTokenType(t *testing.T) {
assert.Equal(t, 1, int(link.RecoveryTokenTypeAdmin))
assert.Equal(t, 2, int(link.RecoveryTokenTypeSelfService))
}

View File

@@ -0,0 +1,86 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link
import (
"context"
"time"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/verification"
"github.com/ory/kratos/x"
"github.com/ory/x/clock"
"github.com/ory/x/randx"
)
type VerificationToken struct {
// ID represents the tokens's unique ID.
//
// required: true
// type: string
// format: uuid
ID uuid.UUID `json:"id" db:"id" faker:"-"`
// Token represents the verification token. It can not be longer than 64 chars!
Token string `json:"-" db:"token"`
// VerifiableAddress links this token to a verification address.
// required: true
VerifiableAddress *identity.VerifiableAddress `json:"verification_address" belongs_to:"identity_verifiable_addresses" fk_id:"identity_verifiable_address_id"`
// ExpiresAt is the time (UTC) when the token expires.
// required: true
ExpiresAt time.Time `json:"expires_at" faker:"time_type" db:"expires_at"`
// IssuedAt is the time (UTC) when the token was issued.
// required: true
IssuedAt time.Time `json:"issued_at" faker:"time_type" db:"issued_at"`
// 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"`
// VerifiableAddressID is a helper struct field for gobuffalo.pop.
VerifiableAddressID uuid.NullUUID `json:"-" faker:"-" db:"identity_verifiable_address_id"`
// FlowID is a helper struct field for gobuffalo.pop.
FlowID uuid.UUID `json:"-" faker:"-" db:"selfservice_verification_flow_id"`
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
}
func (VerificationToken) TableName(ctx context.Context) string {
return "identity_verification_tokens"
}
func NewSelfServiceVerificationToken(address identity.VerifiableAddressLike, f *verification.Flow, expiresIn time.Duration) *VerificationToken {
now := time.Now().UTC()
token := &VerificationToken{
ID: x.NewUUID(),
Token: randx.MustString(32, randx.AlphaNum),
ExpiresAt: now.Add(expiresIn),
IssuedAt: now,
FlowID: f.ID,
}
if address != nil {
if va, isPersistable := address.ToPersistable(); isPersistable {
token.VerifiableAddress = va
token.VerifiableAddressID = uuid.NullUUID{
UUID: va.ID,
Valid: true,
}
}
}
return token
}
func (f *VerificationToken) Valid(c clock.Clock) error {
if f.ExpiresAt.Before(c.Now().UTC()) {
return errors.WithStack(flow.NewFlowExpiredError(c, f.ExpiresAt))
}
return nil
}

View File

@@ -0,0 +1,52 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package link_test
import (
"net/http"
"testing"
"time"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/selfservice/strategy/link"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/x/clock"
"github.com/ory/x/stringslice"
"github.com/ory/x/urlx"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/verification"
)
func TestVerificationToken(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
req := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.com/")}
t.Run("func=NewSelfServiceVerificationToken", func(t *testing.T) {
t.Run("case=creates unique tokens", func(t *testing.T) {
f, err := verification.NewFlow(reg, time.Hour, "", req, nil, flow.TypeBrowser)
require.NoError(t, err)
tokens := make([]string, 10)
for k := range tokens {
tokens[k] = link.NewSelfServiceVerificationToken(nil, f, time.Hour).Token
}
assert.Len(t, stringslice.Unique(tokens), len(tokens))
})
})
t.Run("method=Valid", func(t *testing.T) {
t.Run("case=is invalid when the flow is expired", func(t *testing.T) {
f, err := verification.NewFlow(reg, -time.Hour, "", req, nil, flow.TypeBrowser)
require.NoError(t, err)
token := link.NewSelfServiceVerificationToken(nil, f, -time.Hour)
require.Error(t, token.Valid(clock.New()))
assert.EqualError(t, token.Valid(clock.New()), f.Valid(clock.New()).Error())
})
})
}