From 9741483d1ece578ba6e7f0d6656a9cf2c4ce42d8 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Mon, 18 Mar 2024 16:02:54 -0700 Subject: [PATCH] feat: add verification hook to login flow --- driver/registry_default_hooks.go | 2 + embedx/config.schema.json | 22 ++++++++++ selfservice/flow/login/flow.go | 16 ++++++++ selfservice/flow/login/hook.go | 67 +++++++++++++++++-------------- selfservice/flow/login/session.go | 13 +++++- selfservice/hook/hooks.go | 1 + selfservice/hook/verification.go | 18 ++++++++- 7 files changed, 107 insertions(+), 32 deletions(-) diff --git a/driver/registry_default_hooks.go b/driver/registry_default_hooks.go index 1c436e932d4e..05b9a10f3d98 100644 --- a/driver/registry_default_hooks.go +++ b/driver/registry_default_hooks.go @@ -76,6 +76,8 @@ func (m *RegistryDefault) getHooks(credentialsType string, configs []config.Self i = append(i, m.HookShowVerificationUI()) case hook.KeyTwoStepRegistration: i = append(i, m.HookTwoStepRegistration()) + case hook.KeyVerifier: + i = append(i, m.HookVerifier()) default: var found bool for name, m := range m.injectedSelfserviceHooks { diff --git a/embedx/config.schema.json b/embedx/config.schema.json index 473f0bf514fd..15cc950fbf8e 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -75,6 +75,16 @@ "additionalProperties": false, "required": ["hook"] }, + "selfServiceVerificationHook": { + "type": "object", + "properties": { + "hook": { + "const": "verification" + } + }, + "additionalProperties": false, + "required": ["hook"] + }, "selfServiceShowVerificationUIHook": { "type": "object", "properties": { @@ -735,6 +745,12 @@ }, { "$ref": "#/definitions/selfServiceWebHook" + }, + { + "$ref": "#/definitions/selfServiceVerificationHook" + }, + { + "$ref": "#/definitions/selfServiceShowVerificationUIHook" } ] }, @@ -893,6 +909,12 @@ { "$ref": "#/definitions/selfServiceRequireVerifiedAddressHook" }, + { + "$ref": "#/definitions/selfServiceVerificationHook" + }, + { + "$ref": "#/definitions/selfServiceShowVerificationUIHook" + }, { "$ref": "#/definitions/b2bSSOHook" } diff --git a/selfservice/flow/login/flow.go b/selfservice/flow/login/flow.go index a6bb0d55d60b..9e91deafc678 100644 --- a/selfservice/flow/login/flow.go +++ b/selfservice/flow/login/flow.go @@ -145,6 +145,12 @@ type Flow struct { // // required: false TransientPayload json.RawMessage `json:"transient_payload,omitempty" faker:"-" db:"-"` + + // Contains a list of actions, that could follow this flow + // + // It can, for example, contain a reference to the verification flow, created as part of the user's + // registration. + ContinueWithItems []flow.ContinueWith `json:"-" db:"-" faker:"-" ` } var _ flow.Flow = new(Flow) @@ -301,3 +307,13 @@ func (f *Flow) SetState(state flow.State) { func (t *Flow) GetTransientPayload() json.RawMessage { return t.TransientPayload } + +var _ flow.FlowWithContinueWith = new(Flow) + +func (f *Flow) AddContinueWith(c flow.ContinueWith) { + f.ContinueWithItems = append(f.ContinueWithItems, c) +} + +func (f *Flow) ContinueWith() []flow.ContinueWith { + return f.ContinueWithItems +} diff --git a/selfservice/flow/login/hook.go b/selfservice/flow/login/hook.go index 418e9237df5c..595fdbff7936 100644 --- a/selfservice/flow/login/hook.go +++ b/selfservice/flow/login/hook.go @@ -122,7 +122,7 @@ func (e *HookExecutor) PostLoginHook( w http.ResponseWriter, r *http.Request, g node.UiNodeGroup, - a *Flow, + f *Flow, i *identity.Identity, s *session.Session, provider string, @@ -132,7 +132,7 @@ func (e *HookExecutor) PostLoginHook( r = r.WithContext(ctx) defer otelx.End(span, &err) - if err := e.maybeLinkCredentials(r.Context(), s, i, a); err != nil { + if err := e.maybeLinkCredentials(r.Context(), s, i, f); err != nil { return err } @@ -144,11 +144,11 @@ func (e *HookExecutor) PostLoginHook( // Verify the redirect URL before we do any other processing. returnTo, err := x.SecureRedirectTo(r, c.SelfServiceBrowserDefaultReturnTo(r.Context()), - x.SecureRedirectReturnTo(a.ReturnTo), - x.SecureRedirectUseSourceURL(a.RequestURL), + x.SecureRedirectReturnTo(f.ReturnTo), + x.SecureRedirectUseSourceURL(f.RequestURL), x.SecureRedirectAllowURLs(c.SelfServiceBrowserAllowedReturnToDomains(r.Context())), x.SecureRedirectAllowSelfServiceURLs(c.SelfPublicURL(r.Context())), - x.SecureRedirectOverrideDefaultReturnTo(c.SelfServiceFlowLoginReturnTo(r.Context(), a.Active.String())), + x.SecureRedirectOverrideDefaultReturnTo(c.SelfServiceFlowLoginReturnTo(r.Context(), f.Active.String())), ) if err != nil { return err @@ -165,38 +165,38 @@ func (e *HookExecutor) PostLoginHook( e.d.Logger(). WithRequest(r). WithField("identity_id", i.ID). - WithField("flow_method", a.Active). + WithField("flow_method", f.Active). Debug("Running ExecuteLoginPostHook.") - for k, executor := range e.d.PostLoginHooks(r.Context(), a.Active) { - if err := executor.ExecuteLoginPostHook(w, r, g, a, s); err != nil { + for k, executor := range e.d.PostLoginHooks(r.Context(), f.Active) { + if err := executor.ExecuteLoginPostHook(w, r, g, f, s); err != nil { if errors.Is(err, ErrHookAbortFlow) { e.d.Logger(). WithRequest(r). WithField("executor", fmt.Sprintf("%T", executor)). WithField("executor_position", k). - WithField("executors", PostHookExecutorNames(e.d.PostLoginHooks(r.Context(), a.Active))). + WithField("executors", PostHookExecutorNames(e.d.PostLoginHooks(r.Context(), f.Active))). WithField("identity_id", i.ID). - WithField("flow_method", a.Active). + WithField("flow_method", f.Active). Debug("A ExecuteLoginPostHook hook aborted early.") span.SetAttributes(attribute.String("redirect_reason", "aborted by hook"), attribute.String("executor", fmt.Sprintf("%T", executor))) return nil } - return e.handleLoginError(w, r, g, a, i, err) + return e.handleLoginError(w, r, g, f, i, err) } e.d.Logger(). WithRequest(r). WithField("executor", fmt.Sprintf("%T", executor)). WithField("executor_position", k). - WithField("executors", PostHookExecutorNames(e.d.PostLoginHooks(r.Context(), a.Active))). + WithField("executors", PostHookExecutorNames(e.d.PostLoginHooks(r.Context(), f.Active))). WithField("identity_id", i.ID). - WithField("flow_method", a.Active). + WithField("flow_method", f.Active). Debug("ExecuteLoginPostHook completed successfully.") } - if a.Type == flow.TypeAPI { + if f.Type == flow.TypeAPI { span.SetAttributes(attribute.String("flow_type", string(flow.TypeAPI))) if err := e.d.SessionPersister().UpsertSession(r.Context(), s); err != nil { return errors.WithStack(err) @@ -210,23 +210,27 @@ func (e *HookExecutor) PostLoginHook( span.AddEvent(events.NewLoginSucceeded(r.Context(), &events.LoginSucceededOpts{ SessionID: s.ID, IdentityID: i.ID, - FlowType: string(a.Type), - RequestedAAL: string(a.RequestedAAL), - IsRefresh: a.Refresh, - Method: a.Active.String(), + FlowType: string(f.Type), + RequestedAAL: string(f.RequestedAAL), + IsRefresh: f.Refresh, + Method: f.Active.String(), SSOProvider: provider, })) - if a.IDToken != "" { + if f.IDToken != "" { // We don't want to redirect with the code, if the flow was submitted with an ID token. // This is the case for Sign in with native Apple SDK or Google SDK. - } else if handled, err := e.d.SessionManager().MaybeRedirectAPICodeFlow(w, r, a, s.ID, g); err != nil { + } else if handled, err := e.d.SessionManager().MaybeRedirectAPICodeFlow(w, r, f, s.ID, g); err != nil { return errors.WithStack(err) } else if handled { return nil } - response := &APIFlowResponse{Session: s, Token: s.Token} - if required, _ := e.requiresAAL2(r, classified, a); required { + response := &APIFlowResponse{ + Session: s, + Token: s.Token, + ContinueWith: f.ContinueWith(), + } + if required, _ := e.requiresAAL2(r, classified, f); required { // If AAL is not satisfied, we omit the identity to preserve the user's privacy in case of a phishing attack. response.Session.Identity = nil } @@ -247,7 +251,7 @@ func (e *HookExecutor) PostLoginHook( trace.SpanFromContext(r.Context()).AddEvent(events.NewLoginSucceeded(r.Context(), &events.LoginSucceededOpts{ SessionID: s.ID, - IdentityID: i.ID, FlowType: string(a.Type), RequestedAAL: string(a.RequestedAAL), IsRefresh: a.Refresh, Method: a.Active.String(), + IdentityID: i.ID, FlowType: string(f.Type), RequestedAAL: string(f.RequestedAAL), IsRefresh: f.Refresh, Method: f.Active.String(), SSOProvider: provider, })) @@ -258,7 +262,7 @@ func (e *HookExecutor) PostLoginHook( s.Token = "" // If we detect that whoami would require a higher AAL, we redirect! - if _, err := e.requiresAAL2(r, s, a); err != nil { + if _, err := e.requiresAAL2(r, s, f); err != nil { if aalErr := new(session.ErrAALNotSatisfied); errors.As(err, &aalErr) { span.SetAttributes(attribute.String("return_to", aalErr.RedirectTo), attribute.String("redirect_reason", "requires aal2")) e.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(aalErr.RedirectTo)) @@ -269,10 +273,10 @@ func (e *HookExecutor) PostLoginHook( // If Kratos is used as a Hydra login provider, we need to redirect back to Hydra by returning a 422 status // with the post login challenge URL as the body. - if a.OAuth2LoginChallenge != "" { + if f.OAuth2LoginChallenge != "" { postChallengeURL, err := e.d.Hydra().AcceptLoginRequest(r.Context(), hydra.AcceptLoginRequestParams{ - LoginChallenge: string(a.OAuth2LoginChallenge), + LoginChallenge: string(f.OAuth2LoginChallenge), IdentityID: i.ID.String(), SessionID: s.ID.String(), AuthenticationMethods: s.AMR, @@ -285,13 +289,16 @@ func (e *HookExecutor) PostLoginHook( return nil } - response := &APIFlowResponse{Session: s} + response := &APIFlowResponse{ + Session: s, + ContinueWith: f.ContinueWith(), + } e.d.Writer().Write(w, r, response) return nil } // If we detect that whoami would require a higher AAL, we redirect! - if _, err := e.requiresAAL2(r, s, a); err != nil { + if _, err := e.requiresAAL2(r, s, f); err != nil { if aalErr := new(session.ErrAALNotSatisfied); errors.As(err, &aalErr) { http.Redirect(w, r, aalErr.RedirectTo, http.StatusSeeOther) return nil @@ -300,10 +307,10 @@ func (e *HookExecutor) PostLoginHook( } finalReturnTo := returnTo.String() - if a.OAuth2LoginChallenge != "" { + if f.OAuth2LoginChallenge != "" { rt, err := e.d.Hydra().AcceptLoginRequest(r.Context(), hydra.AcceptLoginRequestParams{ - LoginChallenge: string(a.OAuth2LoginChallenge), + LoginChallenge: string(f.OAuth2LoginChallenge), IdentityID: i.ID.String(), SessionID: s.ID.String(), AuthenticationMethods: s.AMR, diff --git a/selfservice/flow/login/session.go b/selfservice/flow/login/session.go index 8b99d037e15a..35e2aff87b38 100644 --- a/selfservice/flow/login/session.go +++ b/selfservice/flow/login/session.go @@ -3,7 +3,10 @@ package login -import "github.com/ory/kratos/session" +import ( + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/session" +) // The Response for Login Flows via API // @@ -26,4 +29,12 @@ type APIFlowResponse struct { // // required: true Session *session.Session `json:"session"` + + // Contains a list of actions, that could follow this flow + // + // It can, for example, this will contain a reference to the verification flow, created as part of the user's + // registration or the token of the session. + // + // required: false + ContinueWith []flow.ContinueWith `json:"continue_with"` } diff --git a/selfservice/hook/hooks.go b/selfservice/hook/hooks.go index 3b2060c9c6a1..c272f954087f 100644 --- a/selfservice/hook/hooks.go +++ b/selfservice/hook/hooks.go @@ -10,4 +10,5 @@ const ( KeyAddressVerifier = "require_verified_address" KeyVerificationUI = "show_verification_ui" KeyTwoStepRegistration = "two_step_registration" + KeyVerifier = "verification" ) diff --git a/selfservice/hook/verification.go b/selfservice/hook/verification.go index c75cf1ce073b..f098741ac236 100644 --- a/selfservice/hook/verification.go +++ b/selfservice/hook/verification.go @@ -12,10 +12,12 @@ import ( "github.com/ory/kratos/driver/config" "github.com/ory/kratos/identity" "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/flow/login" "github.com/ory/kratos/selfservice/flow/registration" "github.com/ory/kratos/selfservice/flow/settings" "github.com/ory/kratos/selfservice/flow/verification" "github.com/ory/kratos/session" + "github.com/ory/kratos/ui/node" "github.com/ory/kratos/x" "github.com/ory/x/otelx" ) @@ -23,6 +25,7 @@ import ( var ( _ registration.PostHookPostPersistExecutor = new(Verifier) _ settings.PostHookPostPersistExecutor = new(Verifier) + _ login.PostHookExecutor = new(Verifier) ) type ( @@ -34,6 +37,7 @@ type ( verification.FlowPersistenceProvider identity.PrivilegedPoolProvider x.WriterProvider + x.TracingProvider } Verifier struct { r verifierDependencies @@ -61,6 +65,18 @@ func (e *Verifier) ExecuteSettingsPostPersistHook(w http.ResponseWriter, r *http }) } +func (e *Verifier) ExecuteLoginPostHook(w http.ResponseWriter, r *http.Request, g node.UiNodeGroup, f *login.Flow, s *session.Session) (err error) { + ctx, span := e.r.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.hook.Verifier.ExecuteSettingsPostPersistHook") + r = r.WithContext(ctx) + defer otelx.End(span, &err) + if f.RequestedAAL != identity.AuthenticatorAssuranceLevel1 { + span.AddEvent("Skipping verification hook because AAL is not 1") + return nil + } + + return e.do(w, r.WithContext(ctx), s.Identity, f, nil) +} + func (e *Verifier) do( w http.ResponseWriter, r *http.Request, @@ -82,7 +98,7 @@ func (e *Verifier) do( for k := range i.VerifiableAddresses { address := &i.VerifiableAddresses[k] - if address.Status != identity.VerifiableAddressStatusPending { + if address.Verified { continue }