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
369 lines
14 KiB
Go
369 lines
14 KiB
Go
// 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)
|
|
}
|