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
561 lines
19 KiB
Go
561 lines
19 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"
|
|
"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
|
|
}
|