// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package login import ( "cmp" "context" "database/sql" "encoding/json" "net/http" "net/url" "strconv" "strings" "time" "github.com/gofrs/uuid" "github.com/pkg/errors" "github.com/tidwall/gjson" hydraclientgo "github.com/ory/hydra-client-go/v2" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/hydra" "github.com/ory/kratos/identity" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/ui/container" "github.com/ory/kratos/x" "github.com/ory/kratos/x/nosurfx" "github.com/ory/kratos/x/redir" "github.com/ory/pop/v6" "github.com/ory/x/clock" "github.com/ory/x/sqlxx" "github.com/ory/x/urlx" ) // Login Flow // // This object represents a login flow. A login flow is initiated at the "Initiate Login API / Browser Flow" // endpoint by a client. // // Once a login flow is completed successfully, a session cookie or session token will be issued. // // swagger:model loginFlow type Flow struct { // ID represents the flow's unique ID. When performing the login flow, this // represents the id in the login UI's query parameter: http:///?flow= // // required: true ID uuid.UUID `json:"id" faker:"-" db:"id" rw:"r"` NID uuid.UUID `json:"-" faker:"-" db:"nid"` OrganizationID uuid.NullUUID `json:"organization_id,omitempty" faker:"-" db:"organization_id"` // Ory OAuth 2.0 Login Challenge. // // This value is set using the `login_challenge` query parameter of the registration and login endpoints. // If set will cooperate with Ory OAuth2 and OpenID to act as an OAuth2 server / OpenID Provider. OAuth2LoginChallenge sqlxx.NullString `json:"oauth2_login_challenge,omitempty" faker:"-" db:"oauth2_login_challenge_data"` // HydraLoginRequest is an optional field whose presence indicates that Kratos // is being used as an identity provider in a Hydra OAuth2 flow. Kratos // populates this field by retrieving its value from Hydra and it is used by // the login and consent UIs. HydraLoginRequest *hydraclientgo.OAuth2LoginRequest `json:"oauth2_login_request,omitempty" faker:"-" db:"-"` // Type represents the flow's type which can be either "api" or "browser", depending on the flow interaction. // // required: true Type flow.Type `json:"type" db:"type" faker:"flow_type"` // ExpiresAt is the time (UTC) when the flow expires. If the user still wishes to log in, // a new flow has to be initiated. // // required: true ExpiresAt time.Time `json:"expires_at" faker:"time_type" db:"expires_at"` // IssuedAt is the time (UTC) when the flow started. // // required: true IssuedAt time.Time `json:"issued_at" faker:"time_type" db:"issued_at"` // InternalContext stores internal context used by internals - for example MFA keys. InternalContext sqlxx.JSONRawMessage `db:"internal_context" json:"-" faker:"-"` // RequestURL is the initial URL that was requested from Ory Kratos. It can be used // to forward information contained in the URL's path or query for example. // // required: true RequestURL string `json:"request_url" db:"request_url"` // ReturnTo contains the requested return_to URL. ReturnTo string `json:"return_to,omitempty" db:"-"` // The active login method // // If set contains the login method used. If the flow is new, it is unset. Active identity.CredentialsType `json:"active,omitempty" db:"active_method"` // UI contains data which must be shown in the user interface. // // required: true UI *container.Container `json:"ui" db:"ui"` // CreatedAt is a helper struct field for gobuffalo.pop. CreatedAt time.Time `json:"created_at" db:"created_at"` // UpdatedAt is a helper struct field for gobuffalo.pop. UpdatedAt time.Time `json:"updated_at" db:"updated_at"` // CSRFToken contains the anti-csrf token associated with this flow. Only set for browser flows. CSRFToken string `json:"-" db:"csrf_token"` // Refresh stores whether this login flow should enforce re-authentication. Refresh bool `json:"refresh" db:"forced"` // RequestedAAL stores if the flow was requested to update the authenticator assurance level. // // This value can be one of "aal1", "aal2", "aal3". RequestedAAL identity.AuthenticatorAssuranceLevel `json:"requested_aal" faker:"len=4" db:"requested_aal"` // SessionTokenExchangeCode holds the secret code that the client can use to retrieve a session token after the login flow has been completed. // This is only set if the client has requested a session token exchange code, and if the flow is of type "api", // and only on creating the login flow. SessionTokenExchangeCode string `json:"session_token_exchange_code,omitempty" faker:"-" db:"-"` // State represents the state of this request: // // - choose_method: ask the user to choose a method to sign in with // - sent_email: the email has been sent to the user // - passed_challenge: the request was successful and the login challenge was passed. // // required: true State State `json:"state" faker:"-" db:"state"` // Only used internally IDToken string `json:"-" db:"-"` // Only used internally RawIDTokenNonce string `json:"-" db:"-"` // TransientPayload is used to pass data from the login to hooks and email templates // // 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:"-" ` // ReturnToVerification contains the redirect URL for the verification flow. ReturnToVerification string `json:"-" db:"-"` isAccountLinkingFlow bool `db:"-"` // IdentitySchema optionally holds the ID of the identity schema that is used // for this flow. This value can be set by the user when creating the flow and // should be retained when the flow is saved or converted to another flow. IdentitySchema flow.IdentitySchema `json:"identity_schema,omitempty" faker:"-" db:"identity_schema_id"` // TestFlow marks this flow as an admin-created dry-run OIDC test. Test // flows short-circuit the OIDC callback: no identity is persisted and no // session is issued. The captured debug data is returned via the derived // TestContext field below. // // Nullable so that existing rows created before the column was added read // as a SQL NULL (effectively false); see IsTest. TestFlow sql.NullBool `json:"-" faker:"-" db:"test_flow"` // TestContext is the derived API view of test-mode data. It is populated // from InternalContext["test"] during marshal and is nil for regular // login flows. See test_context.go. // // required: false // readOnly: true TestContext *TestContext `json:"test_context,omitempty" faker:"-" db:"-"` } var ( _ flow.Flow = (*Flow)(nil) _ flow.FlowWithContinueWith = (*Flow)(nil) ) // flowDependencies are the dependencies NewFlow needs to construct a login // flow: the configuration (for the lifespan and return-to validation), the // clock, and the CSRF token generator. type flowDependencies interface { config.Provider clock.Provider nosurfx.CSRFTokenGeneratorProvider } func NewFlow(reg flowDependencies, r *http.Request, flowType flow.Type) (*Flow, error) { conf := reg.Config() now := reg.Clock().Now().UTC() exp := conf.SelfServiceFlowLoginRequestLifespan(r.Context()) id := x.NewUUID() requestURL := x.RequestURL(r).String() // Pre-validate the return to URL which is contained in the HTTP request. _, err := redir.SecureRedirectTo(r, conf.SelfServiceBrowserDefaultReturnTo(r.Context()), redir.SecureRedirectUseSourceURL(requestURL), redir.SecureRedirectAllowURLs(conf.SelfServiceBrowserAllowedReturnToDomains(r.Context())), redir.SecureRedirectAllowSelfServiceURLs(conf.SelfPublicURL(r.Context())), ) if err != nil { return nil, err } hydraLoginChallenge, err := hydra.GetLoginChallengeID(conf, r) if err != nil { return nil, err } refresh, _ := strconv.ParseBool(r.URL.Query().Get("refresh")) f := &Flow{ ID: id, OAuth2LoginChallenge: hydraLoginChallenge, ExpiresAt: now.Add(exp), IssuedAt: now, UI: &container.Container{ Method: "POST", Action: flow.AppendFlowTo(urlx.AppendPaths(conf.SelfPublicURL(r.Context()), RouteSubmitFlow), id).String(), }, RequestURL: requestURL, CSRFToken: reg.GenerateCSRFToken(r), Type: flowType, Refresh: refresh, RequestedAAL: identity.AuthenticatorAssuranceLevel(strings.ToLower(cmp.Or( r.URL.Query().Get("aal"), string(identity.AuthenticatorAssuranceLevel1)))), InternalContext: []byte("{}"), State: flow.StateChooseMethod, } if err := flow.SetRequestBaseURL(f, x.BaseURLStringFromContext(r.Context())); err != nil { return nil, err } return f, nil } func (f *Flow) GetType() flow.Type { return f.Type } func (f *Flow) GetRequestURL() string { return f.RequestURL } func (f *Flow) GetID() uuid.UUID { return f.ID } func (f *Flow) GetInternalContext() sqlxx.JSONRawMessage { return f.InternalContext } func (f *Flow) SetInternalContext(bytes sqlxx.JSONRawMessage) { f.InternalContext = bytes } func (f *Flow) GetUI() *container.Container { return f.UI } func (f *Flow) GetState() flow.State { return f.State } func (Flow) GetFlowName() flow.FlowName { return flow.LoginFlow } func (Flow) TableName() string { return "selfservice_login_flows" } func (f *Flow) ContinueWith() []flow.ContinueWith { return f.ContinueWithItems } func (f *Flow) SetReturnToVerification(to string) { f.ReturnToVerification = to } func (f *Flow) GetOAuth2LoginChallenge() sqlxx.NullString { return f.OAuth2LoginChallenge } func (f *Flow) GetHydraLoginRequest() *hydraclientgo.OAuth2LoginRequest { return f.HydraLoginRequest } func (f *Flow) AppendTo(src *url.URL) *url.URL { return flow.AppendFlowTo(src, f.ID) } func (f *Flow) SetState(state flow.State) { f.State = state } func (f *Flow) GetTransientPayload() json.RawMessage { return f.TransientPayload } // IsRefresh returns true if the login flow was triggered to re-authenticate the user. // This is the case if the refresh query parameter is set to true. func (f *Flow) IsRefresh() bool { return f.Refresh } func (f *Flow) Valid(c clock.Clock) error { if f.ExpiresAt.Before(c.Now()) { return errors.WithStack(flow.NewFlowExpiredError(c, f.ExpiresAt)) } return nil } func (f *Flow) EnsureInternalContext() { if !gjson.ParseBytes(f.InternalContext).IsObject() { f.InternalContext = []byte("{}") } } func (f Flow) MarshalJSON() ([]byte, error) { type local Flow f.SetReturnTo() // Populate the derived TestContext field for test-mode flows so the // admin UI sees provider_id and (once captured) debug_payload. if err := (&f).LoadTestContext(); err != nil { return nil, err } return json.Marshal(local(f)) } // UnmarshalJSON drops any incoming test_context. The field is derived from // InternalContext and must only be set via LoadTestContext, otherwise a caller // submitting a Flow blob could forge a debug payload. func (f *Flow) UnmarshalJSON(data []byte) error { type local Flow if err := json.Unmarshal(data, (*local)(f)); err != nil { return err } f.TestContext = nil return nil } func (f *Flow) SetReturnTo() { // Return to is already set, do not overwrite it. if len(f.ReturnTo) > 0 { return } if u, err := url.Parse(f.RequestURL); err == nil { f.ReturnTo = u.Query().Get("return_to") } } func (f *Flow) AfterFind(*pop.Connection) error { f.SetReturnTo() return nil } func (f *Flow) AfterSave(*pop.Connection) error { f.SetReturnTo() return nil } func (f *Flow) SecureRedirectToOpts(ctx context.Context, cfg config.Provider) (opts []redir.SecureRedirectOption) { return []redir.SecureRedirectOption{ redir.SecureRedirectReturnTo(f.ReturnTo), redir.SecureRedirectUseSourceURL(f.RequestURL), redir.SecureRedirectAllowURLs(cfg.Config().SelfServiceBrowserAllowedReturnToDomains(ctx)), redir.SecureRedirectAllowSelfServiceURLs(cfg.Config().SelfPublicURL(ctx)), redir.SecureRedirectOverrideDefaultReturnTo(cfg.Config().SelfServiceFlowLoginReturnTo(ctx, f.Active.String())), } } func (f *Flow) AddContinueWith(c flow.ContinueWith) { f.ContinueWithItems = append(f.ContinueWithItems, c) } func (f *Flow) ToLoggerField() map[string]any { if f == nil { return map[string]any{} } return map[string]any{ "id": f.ID.String(), "return_to": f.ReturnTo, "request_url": f.RequestURL, "active": f.Active, "type": f.Type, "nid": f.NID, "state": f.State, "refresh": f.Refresh, "requested_aal": f.RequestedAAL, } }