// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package code import ( "context" "encoding/json" "net/http" "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" ) func (s *Strategy) VerificationStrategyID() string { return string(verification.VerificationStrategyCode) } // PopulateVerificationMethod set's the appropriate UI nodes on this flow // // If the flow's state is `sent_email`, the `code` input and the success notification is set // Otherwise, the default email input is added. // If the flow is a browser flow, the CSRF token is added to the UI. func (s *Strategy) PopulateVerificationMethod(r *http.Request, f *verification.Flow) error { return s.PopulateMethod(r, f) } func (s *Strategy) decodeVerification(r *http.Request) (*updateVerificationFlowWithCodeMethod, error) { var body updateVerificationFlowWithCodeMethod 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 *updateVerificationFlowWithCodeMethod, err error) error { if f != nil { f.UI.SetCSRF(s.deps.GenerateCSRFToken(r)) email := "" if body != nil { email = body.Email } label := text.NewInfoNodeInputEmail() inputType := node.InputAttributeTypeEmail if channels, chErr := s.deps.Config().CourierChannels(r.Context()); chErr == nil { for _, ch := range channels { if ch.ID == "sms" { label = text.NewInfoNodeInputEmailOrPhone() inputType = node.InputAttributeTypeText break } } } f.UI.GetNodes().Upsert( node.NewInputField("email", email, node.CodeGroup, inputType, node.WithRequiredInputAttribute).WithMetaLabel(label), ) } return err } // swagger:model updateVerificationFlowWithCodeMethod type updateVerificationFlowWithCodeMethod struct { // The email address or phone number to verify // // If the provided address belongs to a valid account, a verification email or SMS will be sent. // // If you want to notify the email address if the account does not exist, see // the [notify_unknown_recipients flag](https://www.ory.com/docs/kratos/self-service/flows/verify-email-account-activation#attempted-verification-notifications) // // If a code was already sent, including this field in the payload will invalidate the sent code and re-send a new code. // // required: false Email string `form:"email" 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"` // Code from the recovery email // // If you want to submit a code, use this field, but make sure to _not_ include the email field, as well. // // required: false Code string `json:"code" form:"code"` // The id of the flow Flow string `json:"-" form:"-"` // Transient data to pass along to any webhooks // // required: false TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"` } // getMethod returns the method of this submission or "" if no method could be found func (body *updateVerificationFlowWithCodeMethod) getMethod() verification.VerificationStrategy { if body.Method != "" { return body.Method } if body.Code != "" { return verification.VerificationStrategyCode } return "" } func (s *Strategy) Verify(w http.ResponseWriter, r *http.Request, f *verification.Flow) (err error) { ctx, span := s.deps.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.code.Strategy.Verify") span.SetAttributes(attribute.String("selfservice_flows_verification_use", s.deps.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 err := flow.MethodEnabledAndAllowed(ctx, f.GetFlowName(), s.VerificationStrategyID(), string(body.getMethod()), s.deps); err != nil { return s.handleVerificationError(r, f, body, err) } if err := f.Valid(s.deps.Clock()); err != nil { return s.handleVerificationError(r, f, body, err) } switch f.State { case flow.StateChooseMethod: fallthrough case flow.StateEmailSent: return s.verificationHandleFormSubmission(ctx, w, r, f, body) 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) handleLinkClick(ctx context.Context, w http.ResponseWriter, r *http.Request, f *verification.Flow, code string) error { // Pre-fill the code if codeField := f.UI.Nodes.Find("code"); codeField != nil { codeField.Attributes.SetValue(code) } // In the verification flow, we can't enforce CSRF if the flow is opened from an email, so we initialize the CSRF // token here, so all subsequent interactions are protected csrfToken := s.deps.CSRFHandler().RegenerateToken(w, r) f.UI.SetCSRF(csrfToken) f.CSRFToken = csrfToken if err := s.deps.VerificationFlowPersister().UpdateVerificationFlow(ctx, f); err != nil { return err } // we always redirect to the browser UI here to allow API flows to complete aswell // TODO: In the future, we might want to redirect to a custom URI scheme here, to allow to open an app on the device of // the user to handle the flow directly. http.Redirect(w, r, f.AppendTo(s.deps.Config().SelfServiceFlowVerificationUI(ctx)).String(), http.StatusSeeOther) return errors.WithStack(flow.ErrCompletedByStrategy) } func (s *Strategy) verificationHandleFormSubmission(ctx context.Context, w http.ResponseWriter, r *http.Request, f *verification.Flow, body *updateVerificationFlowWithCodeMethod) error { if len(body.Code) > 0 { if r.Method == http.MethodGet { // Special case: in the code strategy we send out links as well, that contain the code return s.handleLinkClick(ctx, w, r, f, body.Code) } // If not GET: try to use the submitted code return s.verificationUseCode(ctx, w, r, body.Code, f) } else if len(body.Email) == 0 { // If no code and no email was provided, fail with a validation error return s.handleVerificationError(r, f, body, schema.NewRequiredError("#/email", "email")) } if err := flow.EnsureCSRF(s.deps, r, f.Type, s.deps.Config().DisableAPIFlowEnforcement(ctx), s.deps.GenerateCSRFToken, body.CSRFToken); err != nil { return s.handleVerificationError(r, f, body, err) } if err := s.deps.VerificationCodePersister().DeleteVerificationCodesOfFlow(ctx, f.ID); err != nil { return s.handleVerificationError(r, f, body, err) } via := hackyInferChannel(body.Email) ptc, ptcErr := s.deps.PendingTraitsChangePersister().GetPendingTraitsChangeByVerificationFlow(ctx, f.ID) if ptcErr != nil { if !errors.Is(ptcErr, sqlcon.ErrNoRows()) { return s.handleVerificationError(r, f, body, ptcErr) } if err := s.deps.CodeSender().SendVerificationCode(ctx, f, via, body.Email); err != nil { if !errors.Is(err, ErrUnknownAddress()) { return s.handleVerificationError(r, f, body, err) } // Continue execution } } else { // Re-check whether the proposed address is still unclaimed before resending. // Another identity may have claimed it between the failed code submission and this resend. existingAddr, addrErr := s.deps.PrivilegedIdentityPool().FindVerifiableAddressByValue(ctx, ptc.NewAddressVia, ptc.NewAddressValue) if addrErr != nil && !errors.Is(addrErr, sqlcon.ErrNoRows()) { return s.handleVerificationError(r, f, body, addrErr) } if addrErr == nil && existingAddr.IdentityID != ptc.IdentityID { if err := s.updateVerificationFlowWithMessage(ctx, w, r, f, text.NewErrorValidationDuplicateCredentials(), http.StatusBadRequest); err != nil { return s.retryVerificationFlowWithError(ctx, w, r, f.Type, err) } return errors.WithStack(flow.ErrCompletedByStrategy) } proposedIdentity, err := s.deps.IdentityPool().GetIdentity(ctx, ptc.IdentityID, identity.ExpandDefault) if err != nil { return s.handleVerificationError(r, f, body, err) } if err := s.SendVerificationCode(ctx, f, proposedIdentity, ptc); err != nil { return s.handleVerificationError(r, f, body, err) } } f.State = flow.StateEmailSent if err := s.PopulateVerificationMethod(r, f); err != nil { return s.handleVerificationError(r, f, body, err) } // PopulateVerificationMethod sets the email "code sent" message unconditionally. // Override it with the message that matches the channel the code was sent over. f.UI.Messages.Set(text.VerificationCodeSentMessage(via)) if body.Email != "" { f.UI.Nodes.Append( node.NewInputField("email", body.Email, node.CodeGroup, node.InputAttributeTypeSubmit). WithMetaLabel(text.NewInfoNodeResendOTP()), ) } if err := s.deps.VerificationFlowPersister().UpdateVerificationFlow(ctx, f); err != nil { return s.handleVerificationError(r, f, body, err) } return nil } func (s *Strategy) updateVerificationFlowWithMessage(ctx context.Context, w http.ResponseWriter, r *http.Request, f *verification.Flow, message *text.Message, statusCode int) error { f.UI.Messages.Clear() f.UI.Messages.Add(message) if err := s.deps.VerificationFlowPersister().UpdateVerificationFlow(ctx, f); err != nil { return err } if x.IsBrowserRequest(r) { http.Redirect(w, r, f.AppendTo(s.deps.Config().SelfServiceFlowVerificationUI(ctx)).String(), http.StatusSeeOther) } else { s.deps.Writer().WriteCode(w, r, statusCode, f) } return nil } func (s *Strategy) verificationUseCode(ctx context.Context, w http.ResponseWriter, r *http.Request, codeString string, f *verification.Flow) error { code, err := s.deps.VerificationCodePersister().UseVerificationCode(ctx, f.ID, codeString) if errors.Is(err, ErrCodeNotFound()) { if err := s.updateVerificationFlowWithMessage(ctx, w, r, f, text.NewErrorValidationVerificationCodeInvalidOrAlreadyUsed(), http.StatusOK); err != nil { return s.retryVerificationFlowWithError(ctx, w, r, f.Type, err) } return errors.WithStack(flow.ErrCompletedByStrategy) } else if err != nil { return s.retryVerificationFlowWithError(ctx, w, r, f.Type, err) } var identityID uuid.UUID var addressVia string var ptcIdentity *identity.Identity if code.VerifiableAddress == nil { // Pending-change path: no persisted address — look up by verification flow ID. ptc, ptcErr := s.deps.PendingTraitsChangePersister().GetPendingTraitsChangeByVerificationFlow(ctx, f.ID) if ptcErr != nil { if !errors.Is(ptcErr, sqlcon.ErrNoRows()) { return s.retryVerificationFlowWithError(ctx, w, r, f.Type, ptcErr) } // PTC vanished between code issuance and use — show the standard // "invalid or already used" message. if err := s.updateVerificationFlowWithMessage(ctx, w, r, f, text.NewErrorValidationVerificationCodeInvalidOrAlreadyUsed(), http.StatusOK); err != nil { return s.retryVerificationFlowWithError(ctx, w, r, f.Type, err) } return errors.WithStack(flow.ErrCompletedByStrategy) } // Security note: the verification flow is unauthenticated by design. The // user clicks a link from email/SMS. The pending change is bound to the // identity via IdentityID, but we cannot validate session ownership at // completion time. The security model relies on: // 1. The verification code is sent to the *new* address (attacker needs inbox access). // 2. The verification flow has a TTL (enforced by the flow handler). // 3. The one-time code is consumed on use (cannot be replayed). // Creation of pending changes is gated by a privileged session check; // ApplyPendingTraitsChange revalidates the session atomically with the apply. updatedIdentity, applyErr := s.deps.SettingsHookExecutor().ApplyPendingTraitsChange(ctx, w, r, ptc) if applyErr != nil { if errors.Is(applyErr, identity.ErrConcurrentModification) || errors.Is(applyErr, settings.ErrPendingTraitsChangeSessionInvalid) { if err := s.updateVerificationFlowWithMessage(ctx, w, r, f, text.NewErrorValidationVerificationCodeInvalidOrAlreadyUsed(), http.StatusOK); err != nil { return s.retryVerificationFlowWithError(ctx, w, r, f.Type, err) } return errors.WithStack(flow.ErrCompletedByStrategy) } if errors.Is(applyErr, sqlcon.ErrUniqueViolation()) { if err := s.updateVerificationFlowWithMessage(ctx, w, r, f, text.NewErrorValidationDuplicateCredentials(), http.StatusBadRequest); err != nil { return s.retryVerificationFlowWithError(ctx, w, r, f.Type, err) } 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 := code.VerifiableAddress address.Verified = true verifiedAt := sqlxx.NullTime(time.Now().UTC()) address.VerifiedAt = &verifiedAt address.Status = identity.VerifiableAddressStatusCompleted if err := s.deps.PrivilegedIdentityPool().UpdateVerifiableAddress(ctx, address, "verified", "verified_at", "status"); err != nil { return s.retryVerificationFlowWithError(ctx, w, r, f.Type, err) } addressVia = address.Via identityID = code.VerifiableAddress.IdentityID } var i *identity.Identity if ptcIdentity != nil { i = ptcIdentity } else { i, err = s.deps.IdentityPool().GetIdentity(ctx, identityID, identity.ExpandDefault) if err != nil { return s.retryVerificationFlowWithError(ctx, w, r, f.Type, err) } } // Remainder is shared between both paths. returnTo := f.ContinueURL(ctx, s.deps.Config()) f.UI = &container.Container{ Method: "GET", Action: returnTo.String(), } f.State = flow.StatePassedChallenge f.SetCSRFToken(flow.GetCSRFToken(s.deps, 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.CodeGroup, text.NewInfoNodeLabelContinue()). WithMetaLabel(text.NewInfoNodeLabelContinue())) if err := s.deps.VerificationFlowPersister().UpdateVerificationFlow(ctx, f); err != nil { return s.retryVerificationFlowWithError(ctx, w, r, flow.TypeBrowser, err) } if err := s.deps.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.deps. Logger(). WithRequest(r). WithField("message", message). Debug("A verification flow is being retried because a validation error occurred.") f, err := verification.NewFlow(s.deps, s.deps.Config().SelfServiceFlowVerificationRequestLifespan(ctx), s.deps.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.deps.VerificationFlowPersister().CreateVerificationFlow(ctx, f); err != nil { return s.handleVerificationError(r, f, nil, err) } if x.IsJSONRequest(r) { s.deps.Writer().WriteError(w, r, flow.NewFlowReplacedError(text.NewErrorSystemGeneric("An error occured, please use the new flow.")).WithFlow(f)) } else { http.Redirect(w, r, f.AppendTo(s.deps.Config().SelfServiceFlowVerificationUI(ctx)).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.deps. Logger(). WithRequest(r). WithError(verErr). Debug("A verification flow is being retried because an error occurred.") f, err := verification.NewFlow(s.deps, s.deps.Config().SelfServiceFlowVerificationRequestLifespan(ctx), s.deps.CSRFHandler().RegenerateToken(w, r), r, verification.Strategies{s}, ft) if err != nil { return s.handleVerificationError(r, f, nil, err) } var toReturn error if expired := new(flow.ExpiredError); errors.As(verErr, &expired) { f.UI.Messages.Add(text.NewErrorValidationVerificationFlowExpired(s.deps.Clock(), expired.ExpiredAt)) toReturn = expired.WithFlow(f) } else if err := f.UI.ParseError(node.LinkGroup, verErr); err != nil { return err } if err := s.deps.VerificationFlowPersister().CreateVerificationFlow(ctx, f); err != nil { return s.handleVerificationError(r, f, nil, err) } if x.IsJSONRequest(r) { if toReturn == nil { toReturn = flow.NewFlowReplacedError(text.NewErrorSystemGeneric("An error occured, please retry the flow.")). WithFlow(f) } s.deps.Writer().WriteError(w, r, toReturn) } else { http.Redirect(w, r, f.AppendTo(s.deps.Config().SelfServiceFlowVerificationUI(ctx)).String(), http.StatusSeeOther) } return errors.WithStack(flow.ErrCompletedByStrategy) } func (s *Strategy) SendVerificationCode(ctx context.Context, f *verification.Flow, i *identity.Identity, a identity.VerifiableAddressLike) (err error) { rawCode := GenerateCode() if _, err = s.deps.VerificationCodePersister().CreateVerificationCode(ctx, &CreateVerificationCodeParams{ RawCode: rawCode, ExpiresIn: s.deps.Config().SelfServiceCodeMethodLifespan(ctx), VerifiableAddress: a, FlowID: f.ID, }); err != nil { return err } return s.deps.CodeSender().SendVerificationCodeTo(ctx, f, i, rawCode, a) }