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
445 lines
16 KiB
Go
445 lines
16 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package webauthn
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/trace"
|
|
|
|
"github.com/ory/x/otelx"
|
|
|
|
"github.com/ory/kratos/selfservice/strategy/idfirst"
|
|
|
|
"github.com/ory/kratos/selfservice/flowhelpers"
|
|
"github.com/ory/kratos/session"
|
|
"github.com/ory/kratos/x/webauthnx"
|
|
|
|
"github.com/gofrs/uuid"
|
|
|
|
"github.com/ory/kratos/text"
|
|
"github.com/ory/kratos/ui/node"
|
|
"github.com/ory/kratos/x"
|
|
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
"github.com/go-webauthn/webauthn/webauthn"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
|
|
"github.com/pkg/errors"
|
|
|
|
"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/login"
|
|
"github.com/ory/x/decoderx"
|
|
)
|
|
|
|
var (
|
|
_ login.AAL1FormHydrator = new(Strategy)
|
|
_ login.AAL2FormHydrator = new(Strategy)
|
|
)
|
|
|
|
func (s *Strategy) populateLoginMethodForPasswordless(r *http.Request, sr *login.Flow) error {
|
|
sr.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
|
sr.UI.GetNodes().Append(node.NewInputField("method", "webauthn", node.WebAuthnGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoSelfServiceLoginWebAuthn()))
|
|
return nil
|
|
}
|
|
|
|
func (s *Strategy) populateLoginMethod(r *http.Request, sr *login.Flow, i *identity.Identity, label *text.Message, aal identity.AuthenticatorAssuranceLevel) error {
|
|
id, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), i.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cred, ok := id.GetCredentials(s.ID())
|
|
if !ok {
|
|
// Identity has no webauth
|
|
return nil
|
|
}
|
|
|
|
var conf identity.CredentialsWebAuthnConfig
|
|
if err := json.Unmarshal(cred.Config, &conf); err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
|
|
webAuthCreds := conf.Credentials.ToWebAuthnFiltered(aal, nil)
|
|
if len(webAuthCreds) == 0 {
|
|
// Identity has no webauthn
|
|
return webauthnx.ErrNoCredentials
|
|
}
|
|
|
|
web, err := webauthn.New(s.d.Config().WebAuthnConfig(r.Context()))
|
|
if err != nil {
|
|
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to initiate WebAuth.").WithDebug(err.Error()))
|
|
}
|
|
|
|
options, sessionData, err := web.BeginLogin(webauthnx.NewUser(conf.UserHandle, webAuthCreds, web.Config))
|
|
if err != nil {
|
|
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to initiate WebAuth login.").WithDebug(err.Error()))
|
|
}
|
|
|
|
// Remove the WebAuthn URL from the internal context now that it is set!
|
|
sr.InternalContext, err = sjson.SetBytes(sr.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData), sessionData)
|
|
if err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
|
|
injectWebAuthnOptions, err := json.Marshal(options)
|
|
if err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
|
|
if len(cred.Identifiers) > 0 {
|
|
sr.UI.SetNode(node.NewInputField("identifier", cred.Identifiers[0], node.DefaultGroup, node.InputAttributeTypeHidden))
|
|
}
|
|
|
|
sr.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
|
sr.UI.Nodes.Upsert(webauthnx.NewWebAuthnScript(s.d.Config().SelfPublicURL(r.Context())))
|
|
sr.UI.SetNode(webauthnx.NewWebAuthnLoginTrigger(string(injectWebAuthnOptions)).
|
|
WithMetaLabel(label))
|
|
sr.UI.Nodes.Upsert(webauthnx.NewWebAuthnLoginInput())
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Strategy) handleLoginError(r *http.Request, f *login.Flow, err error) error {
|
|
if f != nil {
|
|
f.UI.Nodes.ResetNodes("webauthn_login")
|
|
if f.Type == flow.TypeBrowser {
|
|
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
|
}
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// Update Login Flow with WebAuthn Method
|
|
//
|
|
// swagger:model updateLoginFlowWithWebAuthnMethod
|
|
type updateLoginFlowWithWebAuthnMethod struct {
|
|
// Identifier is the email or username of the user trying to log in.
|
|
//
|
|
// required: true
|
|
Identifier string `json:"identifier"`
|
|
|
|
// Method should be set to "webAuthn" when logging in using the WebAuthn strategy.
|
|
//
|
|
// required: true
|
|
Method string `json:"method"`
|
|
|
|
// Sending the anti-csrf token is only required for browser login flows.
|
|
CSRFToken string `json:"csrf_token"`
|
|
|
|
// Login a WebAuthn Security Key
|
|
//
|
|
// This must contain the ID of the WebAuthN connection.
|
|
Login string `json:"webauthn_login"`
|
|
|
|
// Transient data to pass along to any webhooks
|
|
//
|
|
// required: false
|
|
TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"`
|
|
}
|
|
|
|
func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, sess *session.Session) (i *identity.Identity, err error) {
|
|
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.webauthn.Strategy.Login")
|
|
defer otelx.End(span, &err)
|
|
|
|
if f.Type != flow.TypeBrowser {
|
|
span.SetAttributes(attribute.String("not_responsible_reason", "flow type is not browser"))
|
|
return nil, flow.ErrStrategyNotResponsible
|
|
}
|
|
|
|
var p updateLoginFlowWithWebAuthnMethod
|
|
if err := decoderx.Decode(r, &p,
|
|
decoderx.HTTPKeepRequestBody(true),
|
|
decoderx.HTTPDecoderSetValidatePayloads(true),
|
|
decoderx.MustHTTPRawJSONSchemaCompiler(loginSchema),
|
|
decoderx.HTTPDecoderJSONFollowsFormFormat()); err != nil {
|
|
return nil, s.handleLoginError(r, f, err)
|
|
}
|
|
f.TransientPayload = p.TransientPayload
|
|
|
|
if len(p.Login) > 0 || p.Method == s.SettingsStrategyID() {
|
|
// This method has only two submit buttons
|
|
p.Method = s.SettingsStrategyID()
|
|
} else {
|
|
span.SetAttributes(attribute.String("not_responsible_reason", "login is not provided and method is not webauthn"))
|
|
return nil, flow.ErrStrategyNotResponsible
|
|
}
|
|
|
|
if err := flow.MethodEnabledAndAllowed(ctx, f.GetFlowName(), s.SettingsStrategyID(), p.Method, s.d); err != nil {
|
|
return nil, s.handleLoginError(r, f, err)
|
|
}
|
|
|
|
if err := flow.EnsureCSRF(s.d, r, f.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, p.CSRFToken); err != nil {
|
|
return nil, s.handleLoginError(r, f, err)
|
|
}
|
|
|
|
if s.d.Config().WebAuthnForPasswordless(ctx) || f.IsRefresh() && f.RequestedAAL == identity.AuthenticatorAssuranceLevel1 {
|
|
return s.loginPasswordless(ctx, w, r, f, &p)
|
|
}
|
|
|
|
return s.loginMultiFactor(ctx, r, f, sess.IdentityID, &p)
|
|
}
|
|
|
|
func (s *Strategy) loginPasswordless(ctx context.Context, w http.ResponseWriter, r *http.Request, f *login.Flow, p *updateLoginFlowWithWebAuthnMethod) (i *identity.Identity, err error) {
|
|
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.webauthn.Strategy.loginPasswordless")
|
|
defer otelx.End(span, &err)
|
|
|
|
if err := login.CheckAAL(f, identity.AuthenticatorAssuranceLevel1); err != nil {
|
|
span.SetAttributes(attribute.String("not_responsible_reason", "requested AAL is not AAL1"))
|
|
return nil, s.handleLoginError(r, f, err)
|
|
}
|
|
|
|
if err := flow.EnsureCSRF(s.d, r, f.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, p.CSRFToken); err != nil {
|
|
return nil, s.handleLoginError(r, f, err)
|
|
}
|
|
|
|
if p.Identifier == "" {
|
|
return nil, s.handleLoginError(r, f, errors.WithStack(herodot.ErrBadRequest().WithReason("identifier is required")))
|
|
}
|
|
|
|
i, _, err = s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, s.ID(), p.Identifier)
|
|
if err != nil {
|
|
time.Sleep(x.RandomDelay(s.d.Config().HasherArgon2(ctx).ExpectedDuration, s.d.Config().HasherArgon2(ctx).ExpectedDeviation))
|
|
return nil, s.handleLoginError(r, f, errors.WithStack(schema.NewNoWebAuthnCredentials()))
|
|
}
|
|
|
|
if len(p.Login) == 0 {
|
|
// Reset all nodes to not confuse users.
|
|
// This is kinda hacky and will probably need to be updated at some point.
|
|
previousNodes := f.UI.Nodes
|
|
f.UI.Nodes = node.Nodes{}
|
|
|
|
if err := s.populateLoginMethod(r, f, i, text.NewInfoSelfServiceLoginContinue(), identity.AuthenticatorAssuranceLevel1); errors.Is(err, webauthnx.ErrNoCredentials) {
|
|
f.UI.Nodes = previousNodes
|
|
return nil, s.handleLoginError(r, f, schema.NewNoWebAuthnCredentials())
|
|
} else if err != nil {
|
|
return nil, s.handleLoginError(r, f, err)
|
|
}
|
|
|
|
// Adds the "Continue" button
|
|
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
|
f.UI.Messages.Add(text.NewInfoLoginWebAuthnPasswordless())
|
|
f.UI.SetNode(node.NewInputField("identifier", p.Identifier, node.DefaultGroup, node.InputAttributeTypeHidden, node.WithRequiredInputAttribute))
|
|
if err := s.d.LoginFlowPersister().UpdateLoginFlow(ctx, f); err != nil {
|
|
return nil, s.handleLoginError(r, f, err)
|
|
}
|
|
|
|
redirectTo := f.AppendTo(s.d.Config().SelfServiceFlowLoginUI(ctx)).String()
|
|
if x.IsJSONRequest(r) {
|
|
s.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(redirectTo))
|
|
} else {
|
|
http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowLoginUI(ctx)).String(), http.StatusSeeOther)
|
|
}
|
|
|
|
return nil, errors.WithStack(flow.ErrCompletedByStrategy)
|
|
}
|
|
|
|
return s.loginAuthenticate(ctx, r, f, i.ID, p, identity.AuthenticatorAssuranceLevel1)
|
|
}
|
|
|
|
func (s *Strategy) loginAuthenticate(ctx context.Context, r *http.Request, f *login.Flow, identityID uuid.UUID, p *updateLoginFlowWithWebAuthnMethod, aal identity.AuthenticatorAssuranceLevel) (_ *identity.Identity, err error) {
|
|
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.webauthn.Strategy.loginAuthenticate")
|
|
defer otelx.End(span, &err)
|
|
|
|
i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, identityID)
|
|
if err != nil {
|
|
return nil, s.handleLoginError(r, f, errors.WithStack(schema.NewNoWebAuthnRegistered()))
|
|
}
|
|
|
|
c, ok := i.GetCredentials(s.ID())
|
|
if !ok {
|
|
return nil, s.handleLoginError(r, f, errors.WithStack(schema.NewNoWebAuthnRegistered()))
|
|
}
|
|
|
|
var o identity.CredentialsWebAuthnConfig
|
|
if err := json.Unmarshal(c.Config, &o); err != nil {
|
|
return nil, s.handleLoginError(r, f, errors.WithStack(herodot.ErrInternalServerError().WithReason("The WebAuthn credentials could not be decoded properly").WithDebug(err.Error()).WithWrap(err)))
|
|
}
|
|
|
|
web, err := webauthn.New(s.d.Config().WebAuthnConfig(ctx))
|
|
if err != nil {
|
|
return nil, s.handleLoginError(r, f, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to get webAuthn config.").WithDebug(err.Error())))
|
|
}
|
|
|
|
webAuthnResponse, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(p.Login))
|
|
if err != nil {
|
|
return nil, s.handleLoginError(r, f, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Unable to parse WebAuthn response.").WithDebug(err.Error())))
|
|
}
|
|
|
|
var webAuthnSess webauthn.SessionData
|
|
if err := json.Unmarshal([]byte(gjson.GetBytes(f.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData)).Raw), &webAuthnSess); err != nil {
|
|
return nil, s.handleLoginError(r, f, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Expected WebAuthN in internal context to be an object but got: %s", err)))
|
|
}
|
|
|
|
webAuthCreds := o.Credentials.ToWebAuthnFiltered(aal, &webAuthnResponse.Response.AuthenticatorData.Flags)
|
|
if f.IsRefresh() {
|
|
webAuthCreds = o.Credentials.ToWebAuthn()
|
|
}
|
|
|
|
validatedCredential, err := web.ValidateLogin(webauthnx.NewUser(o.UserHandle, webAuthCreds, web.Config), webAuthnSess, webAuthnResponse)
|
|
if err != nil {
|
|
return nil, s.handleLoginError(r, f, errors.WithStack(schema.NewWebAuthnVerifierWrongError("#/")))
|
|
}
|
|
|
|
// Persist the updated signature counter and clone warning for W3C WebAuthn
|
|
// clone detection. This must not block the login: the user already proved
|
|
// possession of the authenticator.
|
|
webauthnx.UpdateSignCountAfterLogin(ctx, s.d, i, identity.CredentialsTypeWebAuthn, validatedCredential)
|
|
|
|
// Remove the WebAuthn URL from the internal context now that it is set!
|
|
f.InternalContext, err = sjson.DeleteBytes(f.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData))
|
|
if err != nil {
|
|
return nil, s.handleLoginError(r, f, errors.WithStack(err))
|
|
}
|
|
|
|
f.Active = s.ID()
|
|
if err = s.d.LoginFlowPersister().UpdateLoginFlow(ctx, f); err != nil {
|
|
return nil, s.handleLoginError(r, f, errors.WithStack(herodot.ErrInternalServerError().WithReason("Could not update flow").WithDebug(err.Error())))
|
|
}
|
|
|
|
return i, nil
|
|
}
|
|
|
|
func (s *Strategy) loginMultiFactor(ctx context.Context, r *http.Request, f *login.Flow, identityID uuid.UUID, p *updateLoginFlowWithWebAuthnMethod) (*identity.Identity, error) {
|
|
if err := login.CheckAAL(f, identity.AuthenticatorAssuranceLevel2); err != nil {
|
|
trace.SpanFromContext(ctx).SetAttributes(attribute.String("not_responsible_reason", "requested AAL is not AAL2"))
|
|
return nil, err
|
|
}
|
|
return s.loginAuthenticate(ctx, r, f, identityID, p, identity.AuthenticatorAssuranceLevel2)
|
|
}
|
|
|
|
func (s *Strategy) populateLoginMethodRefresh(r *http.Request, sr *login.Flow) error {
|
|
if sr.Type != flow.TypeBrowser {
|
|
return nil
|
|
}
|
|
|
|
identifier, id, _ := flowhelpers.GuessForcedLoginIdentifier(r, s.d, sr, s.ID())
|
|
if identifier == "" {
|
|
return nil
|
|
}
|
|
|
|
if err := s.populateLoginMethod(r, sr, id, text.NewInfoSelfServiceLoginWebAuthn(), sr.RequestedAAL); errors.Is(err, webauthnx.ErrNoCredentials) {
|
|
return nil
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
|
|
sr.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
|
sr.UI.SetNode(node.NewInputField("identifier", identifier, node.DefaultGroup, node.InputAttributeTypeHidden))
|
|
return nil
|
|
}
|
|
|
|
func (s *Strategy) PopulateLoginMethodFirstFactorRefresh(r *http.Request, sr *login.Flow, _ *session.Session) error {
|
|
return s.populateLoginMethodRefresh(r, sr)
|
|
}
|
|
|
|
func (s *Strategy) PopulateLoginMethodSecondFactorRefresh(r *http.Request, sr *login.Flow) error {
|
|
return s.populateLoginMethodRefresh(r, sr)
|
|
}
|
|
|
|
func (s *Strategy) PopulateLoginMethodFirstFactor(r *http.Request, sr *login.Flow) error {
|
|
if sr.Type != flow.TypeBrowser || !s.d.Config().WebAuthnForPasswordless(r.Context()) {
|
|
return nil
|
|
}
|
|
|
|
ds, err := sr.IdentitySchema.URL(r.Context(), s.d.Config())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
identifierLabel, err := login.GetIdentifierLabelFromSchema(r.Context(), ds.String(), s.d.Config().SecurityDisallowRefInIdentitySchemas(r.Context()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sr.UI.SetNode(node.NewInputField(
|
|
"identifier",
|
|
"",
|
|
node.DefaultGroup,
|
|
node.InputAttributeTypeText,
|
|
node.WithRequiredInputAttribute,
|
|
func(attributes *node.InputAttributes) {
|
|
attributes.Autocomplete = node.InputAttributeAutocompleteUsernameWebauthn
|
|
},
|
|
).WithMetaLabel(identifierLabel))
|
|
|
|
if err := s.populateLoginMethodForPasswordless(r, sr); errors.Is(err, webauthnx.ErrNoCredentials) {
|
|
return nil
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Strategy) PopulateLoginMethodSecondFactor(r *http.Request, sr *login.Flow) error {
|
|
if sr.Type != flow.TypeBrowser || s.d.Config().WebAuthnForPasswordless(r.Context()) {
|
|
return nil
|
|
}
|
|
|
|
// We have done proper validation before so this should never error.
|
|
// The full expansion is required: the form hydrator reads the identity's credentials.
|
|
sess, err := s.d.SessionManager().FetchFromRequest(r.Context(), r, session.ExpandEverything, identity.ExpandEverything)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := s.populateLoginMethod(r, sr, sess.Identity, text.NewInfoSelfServiceLoginWebAuthn(), identity.AuthenticatorAssuranceLevel2); errors.Is(err, webauthnx.ErrNoCredentials) {
|
|
return nil
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, sr *login.Flow, opts ...login.FormHydratorModifier) error {
|
|
if sr.Type != flow.TypeBrowser || !s.d.Config().WebAuthnForPasswordless(r.Context()) {
|
|
return errors.WithStack(idfirst.ErrNoCredentialsFound)
|
|
}
|
|
|
|
o := login.NewFormHydratorOptions(opts)
|
|
|
|
var count int
|
|
if o.IdentityHint != nil {
|
|
var err error
|
|
// If we have an identity hint we can perform identity credentials discovery and
|
|
// hide this credential if it should not be included.
|
|
if count, err = s.CountActiveFirstFactorCredentials(r.Context(), o.IdentityHint.Credentials); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if count > 0 || s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) {
|
|
if err := s.populateLoginMethodForPasswordless(r, sr); errors.Is(err, webauthnx.ErrNoCredentials) {
|
|
if !s.d.Config().SecurityAccountEnumerationMitigate(r.Context()) {
|
|
return errors.WithStack(idfirst.ErrNoCredentialsFound)
|
|
}
|
|
return nil
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if count == 0 {
|
|
return errors.WithStack(idfirst.ErrNoCredentialsFound)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Strategy) PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, sr *login.Flow) error {
|
|
return nil
|
|
}
|