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
1000 lines
34 KiB
Go
1000 lines
34 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package login
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gofrs/uuid"
|
|
"github.com/pkg/errors"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/trace"
|
|
|
|
"github.com/ory/herodot"
|
|
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/schema"
|
|
"github.com/ory/kratos/selfservice/errorx"
|
|
"github.com/ory/kratos/selfservice/flow"
|
|
"github.com/ory/kratos/selfservice/sessiontokenexchange"
|
|
"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/nosurfx"
|
|
"github.com/ory/kratos/x/redir"
|
|
"github.com/ory/nosurf"
|
|
"github.com/ory/x/clock"
|
|
"github.com/ory/x/httprouterx"
|
|
"github.com/ory/x/httpx"
|
|
"github.com/ory/x/logrusx"
|
|
"github.com/ory/x/otelx"
|
|
"github.com/ory/x/otelx/semconv"
|
|
"github.com/ory/x/sqlxx"
|
|
"github.com/ory/x/stringsx"
|
|
"github.com/ory/x/urlx"
|
|
)
|
|
|
|
const (
|
|
RouteInitBrowserFlow = "/self-service/login/browser"
|
|
RouteInitAPIFlow = "/self-service/login/api"
|
|
|
|
RouteGetFlow = "/self-service/login/flows"
|
|
|
|
RouteSubmitFlow = "/self-service/login"
|
|
)
|
|
|
|
type (
|
|
dependencies interface {
|
|
clock.Provider
|
|
HookExecutorProvider
|
|
FlowPersistenceProvider
|
|
errorx.ManagementProvider
|
|
hydra.Provider
|
|
StrategyProvider
|
|
session.HandlerProvider
|
|
session.ManagementProvider
|
|
httpx.WriterProvider
|
|
nosurfx.CSRFTokenGeneratorProvider
|
|
nosurfx.CSRFProvider
|
|
otelx.Provider
|
|
config.Provider
|
|
ErrorHandlerProvider
|
|
sessiontokenexchange.PersistenceProvider
|
|
logrusx.Provider
|
|
TestStrategyProvider
|
|
}
|
|
HandlerProvider interface {
|
|
LoginHandler() *Handler
|
|
}
|
|
Handler struct{ d dependencies }
|
|
)
|
|
|
|
func NewHandler(d dependencies) *Handler { return &Handler{d: d} }
|
|
|
|
func (h *Handler) RegisterPublicRoutes(public *httprouterx.RouterPublic) {
|
|
h.d.CSRFHandler().IgnorePath(RouteInitAPIFlow)
|
|
h.d.CSRFHandler().IgnorePath(RouteSubmitFlow)
|
|
// The test-login-flow delete endpoint is authorized by the HMAC cookie
|
|
// set on the OIDC callback, not the global nosurf middleware.
|
|
h.d.CSRFHandler().IgnorePath(RouteDeleteTestFlow)
|
|
|
|
public.GET(RouteInitBrowserFlow, h.createBrowserLoginFlow)
|
|
public.GET(RouteInitAPIFlow, h.createNativeLoginFlow)
|
|
public.GET(RouteGetFlow, h.getLoginFlow)
|
|
|
|
public.POST(RouteSubmitFlow, h.updateLoginFlow)
|
|
public.GET(RouteSubmitFlow, h.updateLoginFlow)
|
|
|
|
public.DELETE(RouteDeleteTestFlow, h.deleteTestLoginFlow)
|
|
}
|
|
|
|
func (h *Handler) RegisterAdminRoutes(admin *httprouterx.RouterAdmin) {
|
|
admin.GET(RouteInitBrowserFlow, redir.RedirectToPublicRoute(h.d))
|
|
admin.GET(RouteInitAPIFlow, redir.RedirectToPublicRoute(h.d))
|
|
admin.GET(RouteGetFlow, redir.RedirectToPublicRoute(h.d))
|
|
|
|
admin.POST(RouteSubmitFlow, redir.RedirectToPublicRoute(h.d))
|
|
admin.GET(RouteSubmitFlow, redir.RedirectToPublicRoute(h.d))
|
|
|
|
admin.POST(RouteAdminCreateTestFlow, h.adminCreateTestLoginFlow)
|
|
}
|
|
|
|
type FlowOption func(f *Flow)
|
|
|
|
func WithFlowReturnTo(returnTo string) FlowOption {
|
|
return func(f *Flow) {
|
|
f.ReturnTo = returnTo
|
|
}
|
|
}
|
|
|
|
func WithOrganizationID(organizationID uuid.NullUUID) FlowOption {
|
|
return func(f *Flow) {
|
|
f.OrganizationID = organizationID
|
|
}
|
|
}
|
|
|
|
func WithInternalContext(internalContext []byte) FlowOption {
|
|
return func(f *Flow) {
|
|
f.InternalContext = internalContext
|
|
}
|
|
}
|
|
|
|
func WithFormErrorMessage(messages []text.Message) FlowOption {
|
|
return func(f *Flow) {
|
|
for i := range messages {
|
|
f.UI.Messages.Add(&messages[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func WithIsAccountLinking() FlowOption {
|
|
return func(f *Flow) {
|
|
f.isAccountLinkingFlow = true
|
|
}
|
|
}
|
|
|
|
func WithLoginChallenge(loginChallenge string) FlowOption {
|
|
return func(f *Flow) {
|
|
f.OAuth2LoginChallenge = sqlxx.NullString(loginChallenge)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) NewLoginFlow(w http.ResponseWriter, r *http.Request, ft flow.Type, opts ...FlowOption) (*Flow, *session.Session, error) {
|
|
conf := h.d.Config()
|
|
f, err := NewFlow(h.d, r, ft)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
for _, o := range opts {
|
|
o(f)
|
|
}
|
|
|
|
if f.RequestedAAL == "" {
|
|
f.RequestedAAL = identity.AuthenticatorAssuranceLevel1
|
|
}
|
|
|
|
switch cs := stringsx.SwitchExact(string(f.RequestedAAL)); {
|
|
case cs.AddCase(string(identity.AuthenticatorAssuranceLevel1)):
|
|
f.RequestedAAL = identity.AuthenticatorAssuranceLevel1
|
|
identitySchema := ""
|
|
if requestedSchema := r.URL.Query().Get("identity_schema"); requestedSchema != "" {
|
|
identitySchema, err = conf.SelfServiceFlowIdentitySchema(r.Context(), requestedSchema)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
f.IdentitySchema = flow.IdentitySchema(identitySchema)
|
|
case cs.AddCase(string(identity.AuthenticatorAssuranceLevel2)):
|
|
f.RequestedAAL = identity.AuthenticatorAssuranceLevel2
|
|
default:
|
|
return nil, nil, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Unable to parse AuthenticationMethod Assurance Level (AAL): %s", cs.ToUnknownCaseErr()))
|
|
}
|
|
|
|
// We assume an error means the user has no session.
|
|
// The full expansion is required: the strategies' form hydrators and the organization
|
|
// filter read the session's identity.
|
|
sess, err := h.d.SessionManager().FetchFromRequest(r.Context(), r, session.ExpandEverything, identity.ExpandEverything)
|
|
if e := new(session.ErrNoActiveSessionFound); errors.As(err, &e) {
|
|
// No session exists yet
|
|
returnSessionTokenExchangeCode, _ := strconv.ParseBool(r.URL.Query().Get("return_session_token_exchange_code"))
|
|
|
|
if ft == flow.TypeAPI && returnSessionTokenExchangeCode {
|
|
e, err := h.d.SessionTokenExchangePersister().CreateSessionTokenExchanger(r.Context(), f.ID)
|
|
if err != nil {
|
|
return nil, nil, errors.WithStack(err)
|
|
}
|
|
f.SessionTokenExchangeCode = e.InitCode
|
|
}
|
|
|
|
// We can not request an AAL > 1 because we must first verify the first factor.
|
|
if f.RequestedAAL > identity.AuthenticatorAssuranceLevel1 {
|
|
return nil, nil, errors.WithStack(ErrSessionRequiredForHigherAAL())
|
|
}
|
|
|
|
// We are setting refresh to false if no session exists.
|
|
f.Refresh = false
|
|
|
|
goto preLoginHook
|
|
} else if err != nil {
|
|
// Some other error happened - return that one.
|
|
return nil, nil, err
|
|
} else {
|
|
// A session exists already
|
|
if f.Refresh {
|
|
// We are refreshing so let's continue
|
|
goto preLoginHook
|
|
}
|
|
|
|
// We are not refreshing - so are we requesting MFA?
|
|
|
|
// If level is 1 we are not requesting AAL -> we are logged in already.
|
|
if f.RequestedAAL == identity.AuthenticatorAssuranceLevel1 {
|
|
return nil, sess, errors.WithStack(ErrAlreadyLoggedIn())
|
|
}
|
|
|
|
// We are requesting an assurance level which the session already has. So we are not upgrading the session
|
|
// in which case we want to return an error.
|
|
if f.RequestedAAL <= sess.AuthenticatorAssuranceLevel {
|
|
return nil, sess, errors.WithStack(ErrAlreadyLoggedIn())
|
|
}
|
|
|
|
// Looks like we are requesting an AAL which is higher than what the session has.
|
|
goto preLoginHook
|
|
}
|
|
|
|
preLoginHook:
|
|
loginStrategies := h.d.LoginStrategies(r.Context(), PrepareOrganizations(r, f, sess)...)
|
|
for _, s := range loginStrategies {
|
|
var populateErr error
|
|
|
|
switch f.RequestedAAL {
|
|
case identity.AuthenticatorAssuranceLevel1:
|
|
switch s := s.(type) {
|
|
case AAL1FormHydrator:
|
|
switch {
|
|
case f.IsRefresh() && sess != nil:
|
|
// Refreshing takes precedence over identifier_first auth which can not be a refresh flow.
|
|
// Therefor this comes first.
|
|
populateErr = s.PopulateLoginMethodFirstFactorRefresh(r, f, sess)
|
|
case h.d.Config().SelfServiceLoginFlowIdentifierFirstEnabled(r.Context()) && !f.isAccountLinkingFlow:
|
|
populateErr = s.PopulateLoginMethodIdentifierFirstIdentification(r, f)
|
|
default:
|
|
populateErr = s.PopulateLoginMethodFirstFactor(r, f)
|
|
}
|
|
case AAL2FormHydrator:
|
|
continue
|
|
default:
|
|
populateErr = errors.WithStack(
|
|
x.PseudoPanic.WithReason("A login strategy was expected to implement interface AAL1FormHydrator but did not."),
|
|
)
|
|
}
|
|
case identity.AuthenticatorAssuranceLevel2:
|
|
switch s := s.(type) {
|
|
case AAL2FormHydrator:
|
|
switch {
|
|
case f.IsRefresh():
|
|
// Refresh takes precedence.
|
|
populateErr = s.PopulateLoginMethodSecondFactorRefresh(r, f)
|
|
default:
|
|
populateErr = s.PopulateLoginMethodSecondFactor(r, f)
|
|
}
|
|
case AAL1FormHydrator:
|
|
continue
|
|
default:
|
|
populateErr = errors.WithStack(
|
|
x.PseudoPanic.WithReason("A login strategy was expected to implement interface AAL2FormHydrator but did not."),
|
|
)
|
|
}
|
|
}
|
|
|
|
if populateErr != nil {
|
|
return nil, nil, populateErr
|
|
}
|
|
}
|
|
|
|
if f.Refresh {
|
|
f.UI.Messages.Set(text.NewInfoLoginReAuth())
|
|
}
|
|
|
|
if sess != nil && f.RequestedAAL > sess.AuthenticatorAssuranceLevel && f.RequestedAAL > identity.AuthenticatorAssuranceLevel1 {
|
|
f.UI.Messages.Add(text.NewInfoLoginMFA())
|
|
}
|
|
|
|
if err := sortNodes(r.Context(), f.UI.Nodes); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if f.Type == flow.TypeBrowser {
|
|
f.UI.SetCSRF(h.d.GenerateCSRFToken(r))
|
|
}
|
|
|
|
if err := h.d.LoginHookExecutor().PreLoginHook(w, r, f); err != nil {
|
|
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, err)
|
|
return f, sess, nil
|
|
}
|
|
|
|
if err := h.d.LoginFlowPersister().CreateLoginFlow(r.Context(), f); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// If the strategy supports fast login, we can attempt a fast path.
|
|
// We execute the fast login here because it requires the flow to be created first.
|
|
for _, s := range loginStrategies {
|
|
if strategy, ok := s.(FastLoginStrategy); ok {
|
|
if err := strategy.FastLogin2FA(w, r, f, sess); errors.Is(err, flow.ErrStrategyNotResponsible) {
|
|
continue
|
|
} else if errors.Is(err, flow.ErrCompletedByStrategy) {
|
|
span := trace.SpanFromContext(r.Context())
|
|
span.AddEvent(events.NewLoginInitiated(r.Context(), f.ID, ft.String(), f.Refresh, f.OrganizationID, string(f.RequestedAAL)))
|
|
return nil, nil, err
|
|
} else if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
span := trace.SpanFromContext(r.Context())
|
|
span.AddEvent(events.NewLoginInitiated(r.Context(), f.ID, ft.String(), f.Refresh, f.OrganizationID, string(f.RequestedAAL)))
|
|
return f, nil, nil
|
|
}
|
|
|
|
func (h *Handler) FromOldFlow(w http.ResponseWriter, r *http.Request, of Flow) (*Flow, error) {
|
|
nf, _, err := h.NewLoginFlow(w, r, of.Type)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nf.RequestURL = of.RequestURL
|
|
nf.IdentitySchema = of.IdentitySchema
|
|
return nf, nil
|
|
}
|
|
|
|
// Create Native Login Flow Parameters
|
|
//
|
|
// swagger:parameters createNativeLoginFlow
|
|
//
|
|
//nolint:deadcode,unused
|
|
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
|
type createNativeLoginFlow struct {
|
|
// Refresh a login session
|
|
//
|
|
// If set to true, this will refresh an existing login session by
|
|
// asking the user to sign in again. This will reset the
|
|
// authenticated_at time of the session.
|
|
//
|
|
// in: query
|
|
Refresh bool `json:"refresh"`
|
|
|
|
// Request a Specific AuthenticationMethod Assurance Level
|
|
//
|
|
// Use this parameter to upgrade an existing session's authenticator assurance level (AAL). This
|
|
// allows you to ask for multi-factor authentication. When an identity sign in using e.g. username+password,
|
|
// the AAL is 1. If you wish to "upgrade" the session's security by asking the user to perform TOTP / WebAuth/ ...
|
|
// you would set this to "aal2".
|
|
//
|
|
// in: query
|
|
RequestAAL identity.AuthenticatorAssuranceLevel `json:"aal"`
|
|
|
|
// The Session Token of the Identity performing the settings flow.
|
|
//
|
|
// in: header
|
|
SessionToken string `json:"X-Session-Token"`
|
|
|
|
// EnableSessionTokenExchangeCode requests the login flow to include a code that can be used to retrieve the session token
|
|
// after the login flow has been completed.
|
|
//
|
|
// in: query
|
|
EnableSessionTokenExchangeCode bool `json:"return_session_token_exchange_code"`
|
|
|
|
// The URL to return the browser to after the flow was completed.
|
|
//
|
|
// in: query
|
|
ReturnTo string `json:"return_to"`
|
|
|
|
// An optional organization ID that should be used for logging this user in.
|
|
// This parameter is only effective in the Ory Network.
|
|
//
|
|
// required: false
|
|
// in: query
|
|
Organization string `json:"organization"`
|
|
|
|
// Via should contain the identity's credential the code should be sent to. Only relevant in aal2 flows.
|
|
//
|
|
// DEPRECATED: This field is deprecated. Please remove it from your requests. The user will now see a choice
|
|
// of MFA credentials to choose from to perform the second factor instead.
|
|
//
|
|
// in: query
|
|
Via string `json:"via"`
|
|
|
|
// An optional identity schema to use for the login flow.
|
|
//
|
|
// required: false
|
|
// in: query
|
|
IdentitySchema string `json:"identity_schema"`
|
|
}
|
|
|
|
// swagger:route GET /self-service/login/api frontend createNativeLoginFlow
|
|
//
|
|
// # Create Login Flow for Native Apps
|
|
//
|
|
// This endpoint initiates a login flow for native apps that do not use a browser, such as mobile devices, smart TVs, and so on.
|
|
//
|
|
// If a valid provided session cookie or session token is provided, a 400 Bad Request error
|
|
// will be returned unless the URL query parameter `?refresh=true` is set.
|
|
//
|
|
// To fetch an existing login flow call `/self-service/login/flows?flow=<flow_id>`.
|
|
//
|
|
// You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server
|
|
// Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make
|
|
// you vulnerable to a variety of CSRF attacks, including CSRF login attacks.
|
|
//
|
|
// In the case of an error, the `error.id` of the JSON response body can be one of:
|
|
//
|
|
// - `session_already_available`: The user is already signed in.
|
|
// - `session_aal1_required`: Multi-factor auth (e.g. 2fa) was requested but the user has no session yet.
|
|
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
|
|
//
|
|
// This endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...).
|
|
//
|
|
// More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
|
|
//
|
|
// Produces:
|
|
// - application/json
|
|
//
|
|
// Schemes: http, https
|
|
//
|
|
// Responses:
|
|
// 200: loginFlow
|
|
// 400: errorGeneric
|
|
// default: errorGeneric
|
|
//
|
|
// Extensions:
|
|
// x-ory-ratelimit-bucket: kratos-public-medium
|
|
func (h *Handler) createNativeLoginFlow(w http.ResponseWriter, r *http.Request) {
|
|
var err error
|
|
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.createNativeLoginFlow")
|
|
r = r.WithContext(ctx)
|
|
defer otelx.End(span, &err)
|
|
|
|
f, _, err := h.NewLoginFlow(w, r, flow.TypeAPI)
|
|
if err != nil {
|
|
if !errors.Is(err, flow.ErrCompletedByStrategy) {
|
|
h.d.Writer().WriteError(w, r, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
h.d.Writer().Write(w, r, f)
|
|
}
|
|
|
|
// Initialize Browser Login Flow Parameters
|
|
//
|
|
// swagger:parameters createBrowserLoginFlow
|
|
//
|
|
//nolint:deadcode,unused
|
|
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
|
type createBrowserLoginFlow struct {
|
|
// Refresh a login session
|
|
//
|
|
// If set to true, this will refresh an existing login session by
|
|
// asking the user to sign in again. This will reset the
|
|
// authenticated_at time of the session.
|
|
//
|
|
// in: query
|
|
Refresh bool `json:"refresh"`
|
|
|
|
// Request a Specific AuthenticationMethod Assurance Level
|
|
//
|
|
// Use this parameter to upgrade an existing session's authenticator assurance level (AAL). This
|
|
// allows you to ask for multi-factor authentication. When an identity sign in using e.g. username+password,
|
|
// the AAL is 1. If you wish to "upgrade" the session's security by asking the user to perform TOTP / WebAuth/ ...
|
|
// you would set this to "aal2".
|
|
//
|
|
// in: query
|
|
RequestAAL identity.AuthenticatorAssuranceLevel `json:"aal"`
|
|
|
|
// The URL to return the browser to after the flow was completed.
|
|
//
|
|
// in: query
|
|
ReturnTo string `json:"return_to"`
|
|
|
|
// HTTP Cookies
|
|
//
|
|
// When using the SDK in a browser app, on the server side you must include the HTTP Cookie Header
|
|
// sent by the client to your server here. This ensures that CSRF and session cookies are respected.
|
|
//
|
|
// in: header
|
|
// name: Cookie
|
|
Cookies string `json:"Cookie"`
|
|
|
|
// An optional Hydra login challenge. If present, Kratos will cooperate with
|
|
// Ory Hydra to act as an OAuth2 identity provider.
|
|
//
|
|
// The value for this parameter comes from `login_challenge` URL Query parameter sent to your
|
|
// application (e.g. `/login?login_challenge=abcde`).
|
|
//
|
|
// required: false
|
|
// in: query
|
|
HydraLoginChallenge string `json:"login_challenge"`
|
|
|
|
// An optional organization ID that should be used for logging this user in.
|
|
// This parameter is only effective in the Ory Network.
|
|
//
|
|
// required: false
|
|
// in: query
|
|
Organization string `json:"organization"`
|
|
|
|
// Via should contain the identity's credential the code should be sent to. Only relevant in aal2 flows.
|
|
//
|
|
// DEPRECATED: This field is deprecated. Please remove it from your requests. The user will now see a choice
|
|
// of MFA credentials to choose from to perform the second factor instead.
|
|
//
|
|
// in: query
|
|
Via string `json:"via"`
|
|
|
|
// An optional identity schema to use for the login flow.
|
|
//
|
|
// required: false
|
|
// in: query
|
|
IdentitySchema string `json:"identity_schema"`
|
|
}
|
|
|
|
// swagger:route GET /self-service/login/browser frontend createBrowserLoginFlow
|
|
//
|
|
// # Create Login Flow for Browsers
|
|
//
|
|
// This endpoint initializes a browser-based user login flow. This endpoint will set the appropriate
|
|
// cookies and anti-CSRF measures required for browser-based flows.
|
|
//
|
|
// If this endpoint is opened as a link in the browser, it will be redirected to
|
|
// `selfservice.flows.login.ui_url` with the flow ID set as the query parameter `?flow=`. If a valid user session
|
|
// exists already, the browser will be redirected to `urls.default_redirect_url` unless the query parameter
|
|
// `?refresh=true` was set.
|
|
//
|
|
// If this endpoint is called via an AJAX request, the response contains the flow without a redirect. In the
|
|
// case of an error, the `error.id` of the JSON response body can be one of:
|
|
//
|
|
// - `session_already_available`: The user is already signed in.
|
|
// - `session_aal1_required`: Multi-factor auth (e.g. 2fa) was requested but the user has no session yet.
|
|
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
|
|
// - `security_identity_mismatch`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration!
|
|
//
|
|
// The optional query parameter login_challenge is set when using Kratos with
|
|
// Hydra in an OAuth2 flow. See the oauth2_provider.url configuration
|
|
// option.
|
|
//
|
|
// This endpoint is NOT INTENDED for clients that do not have a browser (Chrome, Firefox, ...) as cookies are needed.
|
|
//
|
|
// More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
|
|
//
|
|
// Produces:
|
|
// - application/json
|
|
//
|
|
// Schemes: http, https
|
|
//
|
|
// Responses:
|
|
// 200: loginFlow
|
|
// 303: emptyResponse
|
|
// 400: errorGeneric
|
|
// default: errorGeneric
|
|
//
|
|
// Extensions:
|
|
// x-ory-ratelimit-bucket: kratos-public-medium
|
|
func (h *Handler) createBrowserLoginFlow(w http.ResponseWriter, r *http.Request) {
|
|
var err error
|
|
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.createBrowserLoginFlow")
|
|
r = r.WithContext(ctx)
|
|
defer otelx.End(span, &err)
|
|
|
|
var (
|
|
hydraLoginRequest *hydraclientgo.OAuth2LoginRequest
|
|
hydraLoginChallenge sqlxx.NullString
|
|
)
|
|
if r.URL.Query().Has("login_challenge") {
|
|
var err error
|
|
hydraLoginChallenge, err = hydra.GetLoginChallengeID(h.d.Config(), r)
|
|
if err != nil {
|
|
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
|
|
return
|
|
}
|
|
|
|
hydraLoginRequest, err = h.d.Hydra().GetLoginRequest(ctx, string(hydraLoginChallenge))
|
|
if err != nil {
|
|
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
|
|
return
|
|
}
|
|
|
|
if !hydraLoginRequest.GetSkip() {
|
|
q := r.URL.Query()
|
|
q.Set("refresh", "true")
|
|
r.URL.RawQuery = q.Encode()
|
|
}
|
|
|
|
// on OAuth2 flows, we need to use the RequestURL
|
|
// as the ReturnTo URL.
|
|
// This is because a user might want to switch between
|
|
// different flows, such as login to registration and login to recovery.
|
|
// After completing a complex flow, such as recovery, we want the user
|
|
// to be redirected back to the original OAuth2 login flow.
|
|
if hydraLoginRequest.RequestUrl != "" && h.d.Config().OAuth2ProviderOverrideReturnTo(ctx) {
|
|
// replace the return_to query parameter
|
|
q := r.URL.Query()
|
|
q.Set("return_to", hydraLoginRequest.RequestUrl)
|
|
r.URL.RawQuery = q.Encode()
|
|
}
|
|
}
|
|
|
|
a, sess, err := h.NewLoginFlow(w, r, flow.TypeBrowser)
|
|
if errors.Is(err, ErrAlreadyLoggedIn()) {
|
|
if hydraLoginRequest != nil {
|
|
if !hydraLoginRequest.GetSkip() {
|
|
h.d.SelfServiceErrorManager().Forward(ctx, w, r, errors.WithStack(herodot.ErrForbidden().WithReason("ErrAlreadyLoggedIn indicated we can skip login, but Hydra asked us to refresh")))
|
|
return
|
|
}
|
|
|
|
rt, hydraErr := h.d.Hydra().AcceptLoginRequest(ctx,
|
|
hydra.AcceptLoginRequestParams{
|
|
LoginChallenge: string(hydraLoginChallenge),
|
|
IdentityID: sess.IdentityID.String(),
|
|
SessionID: sess.ID.String(),
|
|
AuthenticationMethods: sess.AMR,
|
|
})
|
|
if hydraErr != nil {
|
|
h.d.SelfServiceErrorManager().Forward(ctx, w, r, hydraErr)
|
|
return
|
|
}
|
|
x.SendFlowCompletedAsRedirectOrJSON(w, r, h.d.Writer(), err, rt)
|
|
return
|
|
}
|
|
|
|
returnTo, redirErr := redir.SecureRedirectTo(r, h.d.Config().SelfServiceBrowserDefaultReturnTo(ctx),
|
|
redir.SecureRedirectAllowSelfServiceURLs(h.d.Config().SelfPublicURL(ctx)),
|
|
redir.SecureRedirectAllowURLs(h.d.Config().SelfServiceBrowserAllowedReturnToDomains(ctx)),
|
|
)
|
|
if redirErr != nil {
|
|
h.d.SelfServiceErrorManager().Forward(ctx, w, r, redirErr)
|
|
return
|
|
}
|
|
|
|
x.SendFlowCompletedAsRedirectOrJSON(w, r, h.d.Writer(), err, returnTo.String())
|
|
return
|
|
} else if errors.Is(err, flow.ErrCompletedByStrategy) {
|
|
return
|
|
} else if err != nil {
|
|
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
|
|
return
|
|
}
|
|
|
|
a.HydraLoginRequest = hydraLoginRequest
|
|
|
|
x.SendFlowCompletedAsRedirectOrJSON(w, r, h.d.Writer(), a, a.AppendTo(h.d.Config().SelfServiceFlowLoginUI(ctx)).String())
|
|
}
|
|
|
|
// Get Login Flow Parameters
|
|
//
|
|
// swagger:parameters getLoginFlow
|
|
//
|
|
//nolint:deadcode,unused
|
|
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
|
type getLoginFlow struct {
|
|
// The Login Flow ID
|
|
//
|
|
// The value for this parameter comes from `flow` URL Query parameter sent to your
|
|
// application (e.g. `/login?flow=abcde`).
|
|
//
|
|
// required: true
|
|
// in: query
|
|
ID string `json:"id"`
|
|
|
|
// HTTP Cookies
|
|
//
|
|
// When using the SDK in a browser app, on the server side you must include the HTTP Cookie Header
|
|
// sent by the client to your server here. This ensures that CSRF and session cookies are respected.
|
|
//
|
|
// in: header
|
|
// name: Cookie
|
|
Cookies string `json:"Cookie"`
|
|
}
|
|
|
|
// swagger:route GET /self-service/login/flows frontend getLoginFlow
|
|
//
|
|
// # Get Login Flow
|
|
//
|
|
// This endpoint returns a login flow's context with, for example, error details and other information.
|
|
//
|
|
// Browser flows expect the anti-CSRF cookie to be included in the request's HTTP Cookie Header.
|
|
// For AJAX requests you must ensure that cookies are included in the request or requests will fail.
|
|
//
|
|
// If you use the browser-flow for server-side apps, the services need to run on a common top-level-domain
|
|
// and you need to forward the incoming HTTP Cookie header to this endpoint:
|
|
//
|
|
// ```js
|
|
// // pseudo-code example
|
|
// router.get('/login', async function (req, res) {
|
|
// const flow = await client.getLoginFlow(req.header('cookie'), req.query['flow'])
|
|
//
|
|
// res.render('login', flow)
|
|
// })
|
|
// ```
|
|
//
|
|
// This request may fail due to several reasons. The `error.id` can be one of:
|
|
//
|
|
// - `session_already_available`: The user is already signed in.
|
|
// - `self_service_flow_expired`: The flow is expired and you should request a new one.
|
|
//
|
|
// More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
|
|
//
|
|
// Produces:
|
|
// - application/json
|
|
//
|
|
// Schemes: http, https
|
|
//
|
|
// Responses:
|
|
// 200: loginFlow
|
|
// 403: errorGeneric
|
|
// 404: errorGeneric
|
|
// 410: errorGeneric
|
|
// default: errorGeneric
|
|
//
|
|
// Extensions:
|
|
// x-ory-ratelimit-bucket: kratos-public-high
|
|
func (h *Handler) getLoginFlow(w http.ResponseWriter, r *http.Request) {
|
|
var err error
|
|
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.getLoginFlow")
|
|
r = r.WithContext(ctx)
|
|
defer otelx.End(span, &err)
|
|
|
|
ar, err := h.d.LoginFlowPersister().GetLoginFlow(ctx, x.ParseUUID(r.URL.Query().Get("id")))
|
|
if err != nil {
|
|
h.d.Writer().WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
// Browser flows must include the CSRF token
|
|
//
|
|
// Resolves: https://github.com/ory/kratos/issues/1282
|
|
//
|
|
// Test flows have their own scheme:
|
|
// - choose_method (uncaptured): public read. The flow carries no PII
|
|
// and was admin-created with a server-minted token that the browser
|
|
// never received, so a CSRF check would always fail.
|
|
// - captured: require the CSRF cookie to match f.CSRFToken. The OIDC
|
|
// callback regenerated the cookie+token pair, so only the browser
|
|
// that completed the round trip can read the captured DebugPayload.
|
|
switch {
|
|
case ar.IsTest():
|
|
// TestContext is a derived field (loaded from internal_context); the
|
|
// SQL fetch above doesn't populate it.
|
|
if err := ar.LoadTestContext(); err != nil {
|
|
h.d.Writer().WriteError(w, r, err)
|
|
return
|
|
}
|
|
if ar.TestContext != nil && ar.TestContext.DebugPayload != nil &&
|
|
!nosurf.VerifyToken(h.d.GenerateCSRFToken(r), ar.CSRFToken) {
|
|
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrForbidden().WithReason("missing or invalid CSRF token")))
|
|
return
|
|
}
|
|
case ar.Type == flow.TypeBrowser && !nosurf.VerifyToken(h.d.GenerateCSRFToken(r), ar.CSRFToken):
|
|
h.d.Writer().WriteError(w, r, nosurfx.CSRFErrorReason(r, h.d))
|
|
return
|
|
}
|
|
|
|
if ar.ExpiresAt.Before(h.d.Clock().Now()) {
|
|
if ar.Type == flow.TypeBrowser {
|
|
redirectURL := flow.GetFlowExpiredRedirectURL(ctx, h.d.Config(), RouteInitBrowserFlow, ar.ReturnTo)
|
|
|
|
h.d.Writer().WriteError(w, r, errors.WithStack(nosurfx.ErrGone().WithID(text.ErrIDSelfServiceFlowExpired).
|
|
WithReason("The login flow has expired. Redirect the user to the login flow init endpoint to initialize a new login flow.").
|
|
WithDetail("redirect_to", redirectURL.String()).
|
|
WithDetail("return_to", ar.ReturnTo)))
|
|
return
|
|
}
|
|
h.d.Writer().WriteError(w, r, errors.WithStack(nosurfx.ErrGone().WithID(text.ErrIDSelfServiceFlowExpired).
|
|
WithReason("The login flow has expired. Call the login flow init API endpoint to initialize a new login flow.").
|
|
WithDetail("api", urlx.AppendPaths(h.d.Config().SelfPublicURL(ctx), RouteInitAPIFlow).String())))
|
|
return
|
|
}
|
|
|
|
if ar.OAuth2LoginChallenge != "" {
|
|
hlr, err := h.d.Hydra().GetLoginRequest(ctx, string(ar.OAuth2LoginChallenge))
|
|
if err != nil {
|
|
// We don't redirect back to the third party on errors because Hydra doesn't
|
|
// give us the 3rd party return_uri when it redirects to the login UI.
|
|
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
|
|
return
|
|
}
|
|
ar.HydraLoginRequest = hlr
|
|
}
|
|
|
|
h.d.Writer().Write(w, r, ar)
|
|
}
|
|
|
|
// Update Login Flow Parameters
|
|
//
|
|
// swagger:parameters updateLoginFlow
|
|
//
|
|
//nolint:deadcode,unused
|
|
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
|
type updateLoginFlow struct {
|
|
// The Login Flow ID
|
|
//
|
|
// The value for this parameter comes from `flow` URL Query parameter sent to your
|
|
// application (e.g. `/login?flow=abcde`).
|
|
//
|
|
// required: true
|
|
// in: query
|
|
Flow string `json:"flow"`
|
|
|
|
// in: body
|
|
// required: true
|
|
Body updateLoginFlowBody
|
|
|
|
// The Session Token of the Identity performing the settings flow.
|
|
//
|
|
// in: header
|
|
SessionToken string `json:"X-Session-Token"`
|
|
|
|
// HTTP Cookies
|
|
//
|
|
// When using the SDK in a browser app, on the server side you must include the HTTP Cookie Header
|
|
// sent by the client to your server here. This ensures that CSRF and session cookies are respected.
|
|
//
|
|
// in: header
|
|
// name: Cookie
|
|
Cookies string `json:"Cookie"`
|
|
}
|
|
|
|
// swagger:model updateLoginFlowBody
|
|
//
|
|
//nolint:deadcode,unused
|
|
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
|
type updateLoginFlowBody struct{}
|
|
|
|
// swagger:route POST /self-service/login frontend updateLoginFlow
|
|
//
|
|
// # Submit a Login Flow
|
|
//
|
|
// Use this endpoint to complete a login flow. This endpoint
|
|
// behaves differently for API and browser flows.
|
|
//
|
|
// API flows expect `application/json` to be sent in the body and responds with
|
|
// - HTTP 200 and a application/json body with the session token on success;
|
|
// - HTTP 410 if the original flow expired with the appropriate error messages set and optionally a `use_flow_id` parameter in the body;
|
|
// - HTTP 400 on form validation errors.
|
|
//
|
|
// Browser flows expect a Content-Type of `application/x-www-form-urlencoded` or `application/json` to be sent in the body and respond with
|
|
// - a HTTP 303 redirect to the post/after login URL or the `return_to` value if it was set and if the login succeeded;
|
|
// - a HTTP 303 redirect to the login UI URL with the flow ID containing the validation errors otherwise.
|
|
//
|
|
// Browser flows with an accept header of `application/json` will not redirect but instead respond with
|
|
// - HTTP 200 and a application/json body with the signed in identity and a `Set-Cookie` header on success;
|
|
// - HTTP 303 redirect to a fresh login flow if the original flow expired with the appropriate error messages set;
|
|
// - HTTP 400 on form validation errors.
|
|
//
|
|
// If this endpoint is called with `Accept: application/json` in the header, the response contains the flow without a redirect. In the
|
|
// case of an error, the `error.id` of the JSON response body can be one of:
|
|
//
|
|
// - `session_already_available`: The user is already signed in.
|
|
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
|
|
// - `security_identity_mismatch`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration!
|
|
// - `browser_location_change_required`: Usually sent when an AJAX request indicates that the browser needs to open a specific URL.
|
|
// Most likely used in Social Sign In flows.
|
|
//
|
|
// More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
|
|
//
|
|
// Schemes: http, https
|
|
//
|
|
// Consumes:
|
|
// - application/json
|
|
// - application/x-www-form-urlencoded
|
|
//
|
|
// Produces:
|
|
// - application/json
|
|
//
|
|
// Header:
|
|
// - Set-Cookie
|
|
//
|
|
// Responses:
|
|
// 200: successfulNativeLogin
|
|
// 303: emptyResponse
|
|
// 400: loginFlow
|
|
// 410: errorGeneric
|
|
// 422: errorBrowserLocationChangeRequired
|
|
// default: errorGeneric
|
|
//
|
|
// Extensions:
|
|
// x-ory-ratelimit-bucket: kratos-public-low
|
|
func (h *Handler) updateLoginFlow(w http.ResponseWriter, r *http.Request) {
|
|
var err error
|
|
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.updateLoginFlow")
|
|
ctx = semconv.ContextWithAttributes(ctx,
|
|
attribute.String(events.AttributeKeySelfServiceStrategyUsed.String(), "login"),
|
|
attribute.String(events.AttributeKeySelfServiceFlowName.String(), "login"),
|
|
)
|
|
r = r.WithContext(ctx)
|
|
defer otelx.End(span, &err)
|
|
|
|
rid, err := flow.GetFlowID(r)
|
|
if err != nil {
|
|
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, nil, "", node.DefaultGroup, err)
|
|
return
|
|
}
|
|
|
|
f, err := h.d.LoginFlowPersister().GetLoginFlow(ctx, rid)
|
|
if err != nil {
|
|
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, err)
|
|
return
|
|
}
|
|
|
|
// The full expansion is required: the session is handed to every login strategy below.
|
|
sess, err := h.d.SessionManager().FetchFromRequest(ctx, r, session.ExpandEverything, identity.ExpandEverything)
|
|
if err == nil {
|
|
if f.Refresh {
|
|
// If we want to refresh, continue the login
|
|
goto continueLogin
|
|
}
|
|
|
|
if f.RequestedAAL > sess.AuthenticatorAssuranceLevel {
|
|
// If we want to upgrade AAL, continue the login
|
|
goto continueLogin
|
|
}
|
|
|
|
if x.IsJSONRequest(r) || f.Type == flow.TypeAPI {
|
|
// We are not upgrading AAL, nor are we refreshing. Error!
|
|
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, errors.WithStack(ErrAlreadyLoggedIn()))
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, h.d.Config().SelfServiceBrowserDefaultReturnTo(ctx).String(), http.StatusSeeOther)
|
|
return
|
|
} else if e := new(session.ErrNoActiveSessionFound); errors.As(err, &e) {
|
|
// Only failure scenario here is if we try to upgrade the session to a higher AAL without actually
|
|
// having a session.
|
|
if f.RequestedAAL > identity.AuthenticatorAssuranceLevel1 {
|
|
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, errors.WithStack(ErrSessionRequiredForHigherAAL()))
|
|
return
|
|
}
|
|
|
|
sess = session.NewInactiveSession()
|
|
} else {
|
|
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, err)
|
|
return
|
|
}
|
|
|
|
continueLogin:
|
|
if err := f.Valid(h.d.Clock()); err != nil {
|
|
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, err)
|
|
return
|
|
}
|
|
|
|
var ct identity.CredentialsType
|
|
var i *identity.Identity
|
|
var group node.UiNodeGroup
|
|
for _, ss := range h.d.AllLoginStrategies() {
|
|
interim, err := ss.Login(w, r, f, sess)
|
|
group = ss.NodeGroup()
|
|
if errors.Is(err, flow.ErrStrategyNotResponsible) {
|
|
continue
|
|
} else if errors.Is(err, flow.ErrCompletedByStrategy) {
|
|
return
|
|
} else if err != nil {
|
|
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ss.ID(), group, err)
|
|
return
|
|
}
|
|
|
|
// What can happen is that we re-authenticate as another user. In this case, we need to use a completely fresh
|
|
// session!
|
|
if sess.IdentityID != uuid.Nil && sess.IdentityID != interim.ID {
|
|
sess = session.NewInactiveSession()
|
|
}
|
|
|
|
method := ss.CompletedAuthenticationMethod(ctx)
|
|
sess.CompletedLoginForMethod(method)
|
|
i = interim
|
|
ct = ss.ID()
|
|
break
|
|
}
|
|
|
|
if i == nil {
|
|
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ct, node.DefaultGroup, errors.WithStack(schema.NewNoLoginStrategyResponsible()))
|
|
return
|
|
}
|
|
|
|
if err := h.d.LoginHookExecutor().PostLoginHook(w, r, group, f, i, sess, ""); err != nil {
|
|
if errors.Is(err, ErrAddressNotVerified()) {
|
|
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ct, node.DefaultGroup, errors.WithStack(schema.NewAddressNotVerifiedError()))
|
|
return
|
|
}
|
|
|
|
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ct, node.DefaultGroup, err)
|
|
return
|
|
}
|
|
}
|