snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
18
selfservice/strategy/link/.schema/email.schema.json
Normal file
18
selfservice/strategy/link/.schema/email.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
29
selfservice/strategy/link/.schema/recovery.schema.json
Normal file
29
selfservice/strategy/link/.schema/recovery.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
29
selfservice/strategy/link/.schema/verification.schema.json
Normal file
29
selfservice/strategy/link/.schema/verification.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
32
selfservice/strategy/link/persistence.go
Normal file
32
selfservice/strategy/link/persistence.go
Normal 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
|
||||
}
|
||||
)
|
||||
14
selfservice/strategy/link/schema.go
Normal file
14
selfservice/strategy/link/schema.go
Normal 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
|
||||
279
selfservice/strategy/link/sender.go
Normal file
279
selfservice/strategy/link/sender.go
Normal 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)
|
||||
}
|
||||
}
|
||||
253
selfservice/strategy/link/sender_test.go
Normal file
253
selfservice/strategy/link/sender_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
89
selfservice/strategy/link/strategy.go
Normal file
89
selfservice/strategy/link/strategy.go
Normal 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 }
|
||||
68
selfservice/strategy/link/strategy_nonrfc_test.go
Normal file
68
selfservice/strategy/link/strategy_nonrfc_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
560
selfservice/strategy/link/strategy_recovery.go
Normal file
560
selfservice/strategy/link/strategy_recovery.go
Normal 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
|
||||
}
|
||||
924
selfservice/strategy/link/strategy_recovery_test.go
Normal file
924
selfservice/strategy/link/strategy_recovery_test.go
Normal 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")
|
||||
})
|
||||
})
|
||||
}
|
||||
35
selfservice/strategy/link/strategy_test.go
Normal file
35
selfservice/strategy/link/strategy_test.go
Normal 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")
|
||||
}
|
||||
368
selfservice/strategy/link/strategy_verification.go
Normal file
368
selfservice/strategy/link/strategy_verification.go
Normal 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)
|
||||
}
|
||||
932
selfservice/strategy/link/strategy_verification_test.go
Normal file
932
selfservice/strategy/link/strategy_verification_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
29
selfservice/strategy/link/stub/default.schema.json
Normal file
29
selfservice/strategy/link/stub/default.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
248
selfservice/strategy/link/test/persistence.go
Normal file
248
selfservice/strategy/link/test/persistence.go
Normal 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")
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
110
selfservice/strategy/link/token_recovery.go
Normal file
110
selfservice/strategy/link/token_recovery.go
Normal 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
|
||||
}
|
||||
57
selfservice/strategy/link/token_recovery_test.go
Normal file
57
selfservice/strategy/link/token_recovery_test.go
Normal 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))
|
||||
}
|
||||
86
selfservice/strategy/link/token_verification.go
Normal file
86
selfservice/strategy/link/token_verification.go
Normal 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
|
||||
}
|
||||
52
selfservice/strategy/link/token_verification_test.go
Normal file
52
selfservice/strategy/link/token_verification_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user