Files
upstream-mirror c60cc7ca75
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
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

559 lines
19 KiB
Go

// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package settings
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
stderrors "errors"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/x/redir"
"github.com/ory/pop/v6"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/ory/x/sqlcon"
"github.com/ory/x/sqlxx"
"go.opentelemetry.io/otel/trace"
"github.com/ory/kratos/x/events"
"github.com/ory/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/schema"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/transaction"
)
type (
PreHookExecutorParams struct {
Flow *Flow
Session *session.Session
}
PreHookExecutor interface {
ExecuteSettingsPreHook(w http.ResponseWriter, r *http.Request, params PreHookExecutorParams) error
}
PreHookExecutorFunc func(w http.ResponseWriter, r *http.Request, params PreHookExecutorParams) error
PostHookPrePersistExecutorParams struct {
Flow *Flow
Identity *identity.Identity
Session *session.Session
}
PostHookPrePersistExecutor interface {
ExecuteSettingsPrePersistHook(w http.ResponseWriter, r *http.Request, params PostHookPrePersistExecutorParams) error
}
PostHookPrePersistExecutorFunc func(w http.ResponseWriter, r *http.Request, params PostHookPrePersistExecutorParams) error
PostHookPostPersistExecutorParams struct {
Flow *Flow
Previous *identity.Identity
Updated *identity.Identity
Session *session.Session
}
PostHookPostPersistExecutor interface {
ExecuteSettingsPostPersistHook(w http.ResponseWriter, r *http.Request, params PostHookPostPersistExecutorParams) error
}
PostHookPostPersistExecutorFunc func(w http.ResponseWriter, r *http.Request, params PostHookPostPersistExecutorParams) error
HooksProvider interface {
PreSettingsHooks(ctx context.Context) ([]PreHookExecutor, error)
PostSettingsPrePersistHooks(ctx context.Context, settingsType string) ([]PostHookPrePersistExecutor, error)
PostSettingsPostPersistHooks(ctx context.Context, settingsType string) ([]PostHookPostPersistExecutor, error)
}
executorDependencies interface {
identity.ManagementProvider
identity.ValidationProvider
identity.PoolProvider
identity.PrivilegedPoolProvider
identity.PendingTraitsChangePersistenceProvider
session.ManagementProvider
session.PersistenceProvider
config.Provider
HandlerProvider
HooksProvider
FlowPersistenceProvider
nosurfx.CSRFTokenGeneratorProvider
logrusx.Provider
httpx.WriterProvider
otelx.Provider
transaction.PersistenceProvider
}
HookExecutor struct {
d executorDependencies
}
HookExecutorProvider interface {
SettingsHookExecutor() *HookExecutor
}
)
func (f PreHookExecutorFunc) ExecuteSettingsPreHook(w http.ResponseWriter, r *http.Request, params PreHookExecutorParams) error {
return f(w, r, params)
}
func (f PostHookPrePersistExecutorFunc) ExecuteSettingsPrePersistHook(w http.ResponseWriter, r *http.Request, params PostHookPrePersistExecutorParams) error {
return f(w, r, params)
}
func (f PostHookPostPersistExecutorFunc) ExecuteSettingsPostPersistHook(w http.ResponseWriter, r *http.Request, params PostHookPostPersistExecutorParams) error {
return f(w, r, params)
}
func PostHookPostPersistExecutorNames(e []PostHookPostPersistExecutor) []string {
names := make([]string, len(e))
for k, ee := range e {
names[k] = fmt.Sprintf("%T", ee)
}
return names
}
func PostHookPrePersistExecutorNames(e []PostHookPrePersistExecutor) []string {
names := make([]string, len(e))
for k, ee := range e {
names[k] = fmt.Sprintf("%T", ee)
}
return names
}
func NewHookExecutor(d executorDependencies) *HookExecutor {
return &HookExecutor{d: d}
}
type PostSettingsHookOption func(o *postSettingsHookOptions)
type postSettingsHookOptions struct {
cb func(ctxUpdate *UpdateContext) error
}
func WithCallback(cb func(ctxUpdate *UpdateContext) error) func(o *postSettingsHookOptions) {
return func(o *postSettingsHookOptions) {
o.cb = cb
}
}
func (e *HookExecutor) handleSettingsError(_ context.Context, _ http.ResponseWriter, r *http.Request, settingsType string, f *Flow, i *identity.Identity, flowError error) error {
if f != nil {
if i != nil {
var group node.UiNodeGroup
switch settingsType {
case "password":
group = node.PasswordGroup
case "oidc":
group = node.OpenIDConnectGroup
}
cont, err := container.NewFromStruct("", group, i.Traits, "traits")
if err != nil {
e.d.Logger().WithError(err).Error("could not update flow UI")
return err
}
for _, n := range cont.Nodes {
// we only set the value and not the whole field because we want to keep types from the initial form generation
f.UI.Nodes.SetValueAttribute(n.ID(), n.Attributes.GetValue())
}
}
if f.Type == flow.TypeBrowser {
f.UI.SetCSRF(e.d.GenerateCSRFToken(r))
}
}
return flowError
}
func (e *HookExecutor) PostSettingsHook(ctx context.Context, w http.ResponseWriter, r *http.Request, settingsType string, ctxUpdate *UpdateContext, i *identity.Identity, opts ...PostSettingsHookOption) (err error) {
ctx, span := e.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.flow.settings.HookExecutor.PostSettingsHook")
defer otelx.End(span, &err)
e.d.Logger().
WithRequest(r).
WithField("identity_id", i.ID).
WithField("flow_method", settingsType).
Debug("Running PostSettingsPrePersistHooks.")
// Verify the redirect URL before we do any other processing.
c := e.d.Config()
returnTo, err := redir.SecureRedirectTo(r, c.SelfServiceBrowserDefaultReturnTo(ctx),
redir.SecureRedirectUseSourceURL(ctxUpdate.Flow.RequestURL),
redir.SecureRedirectAllowURLs(c.SelfServiceBrowserAllowedReturnToDomains(ctx)),
redir.SecureRedirectAllowSelfServiceURLs(c.SelfPublicURL(ctx)),
redir.SecureRedirectOverrideDefaultReturnTo(
e.d.Config().SelfServiceFlowSettingsReturnTo(ctx, settingsType,
ctxUpdate.Flow.AppendTo(e.d.Config().SelfServiceFlowSettingsUI(ctx)))),
)
if err != nil {
return err
}
hookOptions := new(postSettingsHookOptions)
for _, f := range opts {
f(hookOptions)
}
preHooks, err := e.d.PostSettingsPrePersistHooks(ctx, settingsType)
if err != nil {
return err
}
for k, executor := range preHooks {
logFields := logrus.Fields{
"executor": fmt.Sprintf("%T", executor),
"executor_position": k,
"executors": PostHookPrePersistExecutorNames(preHooks),
"identity_id": i.ID,
"flow_method": settingsType,
}
if err := executor.ExecuteSettingsPrePersistHook(w, r, PostHookPrePersistExecutorParams{
Flow: ctxUpdate.Flow,
Identity: i,
Session: ctxUpdate.Session,
}); err != nil {
if errors.Is(err, ErrHookAbortFlow) {
e.d.Logger().WithRequest(r).WithFields(logFields).
Debug("A ExecuteSettingsPrePersistHook hook aborted early.")
return nil
}
var group node.UiNodeGroup
switch settingsType {
case "password":
group = node.PasswordGroup
case "oidc":
group = node.OpenIDConnectGroup
}
return flow.HandleHookError(w, r, ctxUpdate.Flow, i.Traits, group, err, e.d, e.d)
}
e.d.Logger().WithRequest(r).WithFields(logFields).Debug("ExecuteSettingsPrePersistHook completed successfully.")
}
options := []identity.ManagerOption{identity.ManagerExposeValidationErrorsForInternalTypeAssertion}
if e.d.SessionManager().IsPrivileged(ctx, ctxUpdate.Session) {
options = append(options, identity.ManagerAllowWriteProtectedTraits)
}
if len(ctxUpdate.excludeCredentialTypesFromUpdate) > 0 {
options = append(options, identity.ManagerWithoutCredentialTypes(ctxUpdate.excludeCredentialTypesFromUpdate...))
}
if err := e.d.IdentityManager().Update(ctx, i, options...); err != nil {
if errors.Is(err, identity.ErrProtectedFieldModified()) {
e.d.Logger().WithError(err).Debug("Modifying protected field requires re-authentication.")
return errors.WithStack(NewFlowNeedsReAuth())
}
if errors.Is(err, sqlcon.ErrUniqueViolation()) {
return schema.NewDuplicateCredentialsError(err)
}
return err
}
e.d.Logger().
WithRequest(r).
WithField("identity_id", i.ID).
Debug("An identity's settings have been updated.")
ctxUpdate.UpdateIdentity(i)
ctxUpdate.Flow.State = flow.StateSuccess
if hookOptions.cb != nil {
if err := hookOptions.cb(ctxUpdate); err != nil {
return err
}
}
newFlow, err := e.d.SettingsHandler().NewFlow(ctx, w, r, i, ctxUpdate.Session, ctxUpdate.Flow.Type)
if err != nil {
return err
}
ctxUpdate.Flow.UI = newFlow.UI
ctxUpdate.Flow.UI.ResetMessages()
ctxUpdate.Flow.UI.AddMessage(node.DefaultGroup, text.NewInfoSelfServiceSettingsUpdateSuccess())
ctxUpdate.Flow.InternalContext = newFlow.InternalContext
if err := e.d.SettingsFlowPersister().UpdateSettingsFlow(ctx, ctxUpdate.Flow); err != nil {
return err
}
postHooks, err := e.d.PostSettingsPostPersistHooks(ctx, settingsType)
if err != nil {
return err
}
previous := ctxUpdate.GetSessionIdentity()
for k, executor := range postHooks {
if err := executor.ExecuteSettingsPostPersistHook(w, r, PostHookPostPersistExecutorParams{
Flow: ctxUpdate.Flow,
Previous: previous,
Updated: i,
Session: ctxUpdate.Session,
}); err != nil {
if errors.Is(err, ErrHookAbortFlow) {
e.d.Logger().
WithRequest(r).
WithField("executor", fmt.Sprintf("%T", executor)).
WithField("executor_position", k).
WithField("executors", PostHookPostPersistExecutorNames(postHooks)).
WithField("identity_id", i.ID).
WithField("flow_method", settingsType).
Debug("A ExecuteSettingsPostPersistHook hook aborted early.")
return nil
}
return e.handleSettingsError(ctx, w, r, settingsType, ctxUpdate.Flow, i, err)
}
e.d.Logger().WithRequest(r).
WithField("executor", fmt.Sprintf("%T", executor)).
WithField("executor_position", k).
WithField("executors", PostHookPostPersistExecutorNames(postHooks)).
WithField("identity_id", i.ID).
WithField("flow_method", settingsType).
Debug("ExecuteSettingsPostPersistHook completed successfully.")
}
e.d.Logger().
WithRequest(r).
WithField("identity_id", i.ID).
WithField("flow_method", settingsType).
Debug("Completed all PostSettingsPrePersistHooks and PostSettingsPostPersistHooks.")
trace.SpanFromContext(ctx).AddEvent(events.NewSettingsSucceeded(
ctx, ctxUpdate.Flow.ID, i.ID, string(ctxUpdate.Flow.Type), settingsType))
if ctxUpdate.Flow.Type == flow.TypeAPI {
updatedFlow, err := e.d.SettingsFlowPersister().GetSettingsFlow(ctx, ctxUpdate.Flow.ID)
if err != nil {
return err
}
// ContinueWith items are transient items, not stored in the database, and need to be carried over here, so
// they can be returned to the client.
updatedFlow.ContinueWithItems = ctxUpdate.Flow.ContinueWithItems
e.d.Writer().Write(w, r, updatedFlow)
return nil
}
if err := e.d.SessionManager().IssueCookie(ctx, w, r, ctxUpdate.Session); err != nil {
return errors.WithStack(err)
}
if x.IsJSONRequest(r) {
updatedFlow, err := e.d.SettingsFlowPersister().GetSettingsFlow(ctx, ctxUpdate.Flow.ID)
if err != nil {
return err
}
// ContinueWith items are transient items, not stored in the database, and need to be carried over here, so
// they can be returned to the client.
ctxUpdate.Flow.AddContinueWith(flow.NewContinueWithRedirectBrowserTo(returnTo.String()))
updatedFlow.ContinueWithItems = ctxUpdate.Flow.ContinueWithItems
e.d.Writer().Write(w, r, updatedFlow)
return nil
}
redir.ContentNegotiationRedirection(w, r, i.CopyWithoutCredentials(), e.d.Writer(), returnTo.String())
return nil
}
// settingsTypePendingTraitsChange is the settings strategy the verify_new_address
// hook is allowed to be configured under. The JSON config schema
// (selfServiceAfterSettingsProfileMethod) only admits verify_new_address inside
// the profile hook list, so a pending traits change can only ever originate
// from the profile strategy.
const settingsTypePendingTraitsChange = "profile"
// ErrPendingTraitsChangeSessionInvalid is returned by ApplyPendingTraitsChange
// when the session that created the pending change is missing, revoked,
// expired, disabled, or belongs to a different tenant. Callers should treat
// this as "the verification attempt is invalid" — the change is not applied.
var ErrPendingTraitsChangeSessionInvalid = stderrors.New("pending traits change session is no longer valid")
// ApplyPendingTraitsChange commits a pending traits change to the identity and
// fires the "after profile settings, post-persist" hook chain. It is the
// single entry point verification strategies use to resolve a pending change
// created by a settings flow.
//
// The originating session is validated inside the same transaction that
// applies the change, so a session that is revoked between the caller's
// token/code consumption and this call is still rejected. If validation
// succeeds, the loaded session is reused for the hook chain — no second
// GetSession round-trip.
//
// Returns ErrPendingTraitsChangeSessionInvalid when the session is not usable.
// Returns identity.ErrConcurrentModification when the identity's traits have
// drifted since the change was proposed.
func (e *HookExecutor) ApplyPendingTraitsChange(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
ptc *identity.PendingTraitsChange,
) (i *identity.Identity, err error) {
ctx, span := e.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.flow.settings.HookExecutor.ApplyPendingTraitsChange")
defer otelx.End(span, &err)
// Reject PTCs whose FK references were nulled or never written. The
// session FK is ON DELETE SET NULL and the origin-flow FK is ON
// DELETE CASCADE, so a NULL here means either a revoked session or
// a writer that failed to set the link.
if !ptc.SessionID.Valid || !ptc.OriginSettingsFlowID.Valid {
return nil, errors.WithStack(ErrPendingTraitsChangeSessionInvalid)
}
var (
sess *session.Session
// Capture the pre-change identity before sess.Identity is overwritten
// below so post-persist hooks (notify_previous_addresses) can compute
// diffs without re-reading the row.
previous *identity.Identity
)
err = e.d.TransactionalPersisterProvider().Transaction(ctx, func(ctx context.Context, _ *pop.Connection) error {
// ExpandDefault loads the session's identity in the same query,
// so IsActive() below checks the identity's active state and we
// avoid a second round-trip to IdentityPool.GetIdentity for the
// hash comparison.
s, sErr := e.d.SessionPersister().GetSession(ctx, ptc.SessionID.UUID, session.ExpandDefault)
if sErr != nil {
if errors.Is(sErr, sqlcon.ErrNoRows()) {
return errors.WithStack(ErrPendingTraitsChangeSessionInvalid)
}
return sErr
}
if s.NID != ptc.NID || !s.IsActive() {
return errors.WithStack(ErrPendingTraitsChangeSessionInvalid)
}
// TODO(jonas): We can likely drop the identity id column entirely, since the session's identity is always loaded and we check the ID matches here. This would simplify the schema and remove a potential source of FK inconsistency.
if s.Identity == nil || s.Identity.ID != ptc.IdentityID {
return errors.WithStack(ErrPendingTraitsChangeSessionInvalid)
}
previous = s.Identity
sess = s
if identity.HashTraits(json.RawMessage(s.Identity.Traits)) != ptc.OriginalTraitsHash {
return identity.ErrConcurrentModification
}
if err := e.d.IdentityManager().UpdateTraits(
ctx, ptc.IdentityID, identity.Traits(ptc.ProposedTraits),
identity.ManagerAllowWriteProtectedTraits,
); err != nil {
return err
}
// The PTC Status column is informational only; the real
// single-submit guard is the verification flow state machine
// (StatePassedChallenge) plus atomic token/code consumption.
// We intentionally do not write Status here.
// Re-read the identity after UpdateTraits so the returned
// snapshot reflects the freshly-committed state, including any
// VerifiableAddress row UpdateTraits may have created for the
// new address.
updated, err := e.d.IdentityPool().GetIdentity(ctx, ptc.IdentityID, identity.ExpandDefault)
if err != nil {
return err
}
for idx := range updated.VerifiableAddresses {
a := &updated.VerifiableAddresses[idx]
if a.Value == ptc.NewAddressValue && a.Via == ptc.NewAddressVia {
a.Verified = true
verifiedAt := sqlxx.NullTime(time.Now().UTC())
a.VerifiedAt = &verifiedAt
a.Status = identity.VerifiableAddressStatusCompleted
if err := e.d.PrivilegedIdentityPool().UpdateVerifiableAddress(ctx, a, "verified", "verified_at", "status"); err != nil {
return err
}
break
}
}
i = updated
// Keep Session.Identity in sync so post-persist webhook templates render the committed traits and the newly-verified address.
// TODO: pointer aliasing could cause issues if a downstream hook implementation modifies the identity in-place instead of treating it as immutable. We should either enforce immutability or document this behavior.
sess.Identity = updated
return nil
})
if err != nil {
return nil, err
}
// Fire the "profile" post-persist hooks. Load the originating settings flow
// so the hook chain receives a real Flow (honest ID, type, RequestURL)
// instead of a synthetic one. The FK is ON DELETE CASCADE, so if the flow
// was cleaned up the PTC was already deleted and we would not reach this
// call. Apply has already committed, so a hook error cannot roll the change
// back — we propagate errors so callers can surface them in the
// verification UI.
originFlow, err := e.d.SettingsFlowPersister().GetSettingsFlow(ctx, ptc.OriginSettingsFlowID.UUID)
if err != nil {
return nil, err
}
postHooks, err := e.d.PostSettingsPostPersistHooks(ctx, settingsTypePendingTraitsChange)
if err != nil {
return nil, err
}
for k, executor := range postHooks {
logFields := logrus.Fields{
"executor": fmt.Sprintf("%T", executor),
"executor_position": k,
"executors": PostHookPostPersistExecutorNames(postHooks),
"identity_id": i.ID,
"flow_method": settingsTypePendingTraitsChange,
}
if err := executor.ExecuteSettingsPostPersistHook(w, r, PostHookPostPersistExecutorParams{
Flow: originFlow,
Previous: previous,
Updated: i,
Session: sess,
}); err != nil {
if errors.Is(err, ErrHookAbortFlow) {
e.d.Logger().WithRequest(r).WithFields(logFields).
Debug("A post-persist hook aborted the pending-traits-change continuation flow.")
return i, nil
}
return nil, err
}
e.d.Logger().WithRequest(r).WithFields(logFields).
Debug("ExecuteSettingsPostPersistHook completed successfully after pending traits change.")
}
return i, nil
}
func (e *HookExecutor) PreSettingsHook(ctx context.Context, w http.ResponseWriter, r *http.Request, a *Flow, s *session.Session) (err error) {
ctx, span := e.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.flow.settings.HookExecutor.PreSettingsHook")
defer otelx.End(span, &err)
hooks, err := e.d.PreSettingsHooks(ctx)
if err != nil {
return err
}
for _, executor := range hooks {
if err := executor.ExecuteSettingsPreHook(w, r, PreHookExecutorParams{
Flow: a,
Session: s,
}); err != nil {
return err
}
}
return nil
}