snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
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
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
This commit is contained in:
428
selfservice/strategy/webauthn/settings.go
Normal file
428
selfservice/strategy/webauthn/settings.go
Normal file
@@ -0,0 +1,428 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
||||
"github.com/ory/x/otelx"
|
||||
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/kratos/x/webauthnx"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"github.com/ory/x/sqlcon"
|
||||
"github.com/ory/x/sqlxx"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
|
||||
"github.com/ory/kratos/session"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/x/decoderx"
|
||||
|
||||
"github.com/ory/kratos/continuity"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/settings"
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
func (s *Strategy) SettingsStrategyID() string {
|
||||
return identity.CredentialsTypeWebAuthn.String()
|
||||
}
|
||||
|
||||
const (
|
||||
InternalContextKeySessionData = "session_data"
|
||||
InternalContextKeyWebauthnOptions = "session_options"
|
||||
)
|
||||
|
||||
// Update Settings Flow with WebAuthn Method
|
||||
//
|
||||
// swagger:model updateSettingsFlowWithWebAuthnMethod
|
||||
type updateSettingsFlowWithWebAuthnMethod struct {
|
||||
// Register a WebAuthn Security Key
|
||||
//
|
||||
// It is expected that the JSON returned by the WebAuthn registration process
|
||||
// is included here.
|
||||
Register string `json:"webauthn_register"`
|
||||
|
||||
// Name of the WebAuthn Security Key to be Added
|
||||
//
|
||||
// A human-readable name for the security key which will be added.
|
||||
RegisterDisplayName string `json:"webauthn_register_displayname"`
|
||||
|
||||
// Remove a WebAuthn Security Key
|
||||
//
|
||||
// This must contain the ID of the WebAuthN connection.
|
||||
Remove string `json:"webauthn_remove"`
|
||||
|
||||
// CSRFToken is the anti-CSRF token
|
||||
CSRFToken string `json:"csrf_token"`
|
||||
|
||||
// Method
|
||||
//
|
||||
// Should be set to "webauthn" when trying to add, update, or remove a webAuthn pairing.
|
||||
//
|
||||
// required: true
|
||||
Method string `json:"method"`
|
||||
|
||||
// Flow is flow ID.
|
||||
//
|
||||
// swagger:ignore
|
||||
Flow string `json:"flow"`
|
||||
|
||||
// Transient data to pass along to any webhooks
|
||||
//
|
||||
// required: false
|
||||
TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"`
|
||||
}
|
||||
|
||||
func (p *updateSettingsFlowWithWebAuthnMethod) GetFlowID() uuid.UUID {
|
||||
return x.ParseUUID(p.Flow)
|
||||
}
|
||||
|
||||
func (p *updateSettingsFlowWithWebAuthnMethod) SetFlowID(rid uuid.UUID) {
|
||||
p.Flow = rid.String()
|
||||
}
|
||||
|
||||
func (s *Strategy) Settings(ctx context.Context, w http.ResponseWriter, r *http.Request, f *settings.Flow, ss *session.Session) (_ *settings.UpdateContext, err error) {
|
||||
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.webauthn.Strategy.Settings")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
if f.Type != flow.TypeBrowser {
|
||||
span.SetAttributes(attribute.String("not_responsible_reason", "flow is not a browser flow"))
|
||||
return nil, flow.ErrStrategyNotResponsible
|
||||
}
|
||||
var p updateSettingsFlowWithWebAuthnMethod
|
||||
ctxUpdate, err := settings.PrepareUpdate(s.d, w, r, f, ss, settings.ContinuityKey(s.SettingsStrategyID()), &p)
|
||||
if errors.Is(err, settings.ErrContinuePreviousAction) {
|
||||
return ctxUpdate, s.continueSettingsFlow(ctx, w, r, ctxUpdate, p)
|
||||
} else if err != nil {
|
||||
return ctxUpdate, s.handleSettingsError(ctx, w, r, ctxUpdate, p, err)
|
||||
}
|
||||
|
||||
if err := s.decodeSettingsFlow(r, &p); err != nil {
|
||||
return ctxUpdate, s.handleSettingsError(ctx, w, r, ctxUpdate, p, err)
|
||||
}
|
||||
|
||||
if len(p.Register)+len(p.Remove) > 0 {
|
||||
// This method has only two submit buttons
|
||||
p.Method = s.SettingsStrategyID()
|
||||
if err := flow.MethodEnabledAndAllowed(ctx, f.GetFlowName(), s.SettingsStrategyID(), p.Method, s.d); err != nil {
|
||||
return nil, s.handleSettingsError(ctx, w, r, ctxUpdate, p, err)
|
||||
}
|
||||
} else {
|
||||
span.SetAttributes(attribute.String("not_responsible_reason", "neither register nor remove is set"))
|
||||
return nil, errors.WithStack(flow.ErrStrategyNotResponsible)
|
||||
}
|
||||
|
||||
// This does not come from the payload!
|
||||
p.Flow = ctxUpdate.Flow.ID.String()
|
||||
if err := s.continueSettingsFlow(ctx, w, r, ctxUpdate, p); err != nil {
|
||||
return ctxUpdate, s.handleSettingsError(ctx, w, r, ctxUpdate, p, err)
|
||||
}
|
||||
|
||||
return ctxUpdate, nil
|
||||
}
|
||||
|
||||
func (s *Strategy) decodeSettingsFlow(r *http.Request, dest interface{}) error {
|
||||
compiler, err := decoderx.HTTPRawJSONSchemaCompiler(settingsSchema)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return decoderx.Decode(r, dest, compiler,
|
||||
decoderx.HTTPKeepRequestBody(true),
|
||||
decoderx.HTTPDecoderAllowedMethods("POST", "GET"),
|
||||
decoderx.HTTPDecoderSetValidatePayloads(true),
|
||||
decoderx.HTTPDecoderJSONFollowsFormFormat(),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Strategy) continueSettingsFlow(
|
||||
ctx context.Context,
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithWebAuthnMethod,
|
||||
) error {
|
||||
if len(p.Register+p.Remove) > 0 {
|
||||
if err := flow.MethodEnabledAndAllowed(ctx, flow.SettingsFlow, s.SettingsStrategyID(), s.SettingsStrategyID(), s.d); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := flow.EnsureCSRF(s.d, r, ctxUpdate.Flow.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, p.CSRFToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !s.d.SessionManager().IsPrivileged(ctx, ctxUpdate.Session) {
|
||||
return errors.WithStack(settings.NewFlowNeedsReAuth())
|
||||
}
|
||||
} else {
|
||||
return errors.New("ended up in unexpected state")
|
||||
}
|
||||
|
||||
if len(p.Register) > 0 {
|
||||
return s.continueSettingsFlowAdd(ctx, ctxUpdate, p)
|
||||
} else if len(p.Remove) > 0 {
|
||||
return s.continueSettingsFlowRemove(ctx, w, r, ctxUpdate, p)
|
||||
}
|
||||
|
||||
return errors.New("ended up in unexpected state")
|
||||
}
|
||||
|
||||
func (s *Strategy) continueSettingsFlowRemove(ctx context.Context, w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithWebAuthnMethod) error {
|
||||
i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, ctxUpdate.Session.IdentityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cred, ok := i.GetCredentials(s.ID())
|
||||
if !ok {
|
||||
return errors.WithStack(herodot.ErrBadRequest().WithReasonf("You tried to remove a WebAuthn but you have no WebAuthn set up."))
|
||||
}
|
||||
|
||||
var cc identity.CredentialsWebAuthnConfig
|
||||
if err := json.Unmarshal(cred.Config, &cc); err != nil {
|
||||
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to decode identity credentials.").WithDebug(err.Error()))
|
||||
}
|
||||
|
||||
var wasPasswordless bool
|
||||
updated := make([]identity.CredentialWebAuthn, 0)
|
||||
for k, cred := range cc.Credentials {
|
||||
if fmt.Sprintf("%x", cred.ID) != p.Remove {
|
||||
updated = append(updated, cc.Credentials[k])
|
||||
} else if cred.IsPasswordless {
|
||||
wasPasswordless = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(updated) == len(cc.Credentials) {
|
||||
return errors.WithStack(herodot.ErrBadRequest().WithReasonf("You tried to remove a WebAuthn credential which does not exist."))
|
||||
}
|
||||
|
||||
count, err := s.d.IdentityManager().CountActiveFirstFactorCredentials(ctx, i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if count < 2 && wasPasswordless {
|
||||
return s.handleSettingsError(ctx, w, r, ctxUpdate, p, errors.WithStack(webauthnx.ErrNotEnoughCredentials()))
|
||||
}
|
||||
|
||||
if len(updated) == 0 {
|
||||
i.DeleteCredentialsType(identity.CredentialsTypeWebAuthn)
|
||||
ctxUpdate.UpdateIdentity(i)
|
||||
return nil
|
||||
}
|
||||
|
||||
cc.Credentials = updated
|
||||
cred.Config, err = json.Marshal(cc)
|
||||
if err != nil {
|
||||
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to encode identity credentials.").WithDebug(err.Error()))
|
||||
}
|
||||
|
||||
i.SetCredentials(s.ID(), *cred)
|
||||
ctxUpdate.UpdateIdentity(i)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) continueSettingsFlowAdd(ctx context.Context, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithWebAuthnMethod) error {
|
||||
webAuthnSession := gjson.GetBytes(ctxUpdate.Flow.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData))
|
||||
if !webAuthnSession.IsObject() {
|
||||
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Expected WebAuthN in internal context to be an object."))
|
||||
}
|
||||
|
||||
var webAuthnSess webauthn.SessionData
|
||||
if err := json.Unmarshal([]byte(gjson.GetBytes(ctxUpdate.Flow.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData)).Raw), &webAuthnSess); err != nil {
|
||||
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Expected WebAuthN in internal context to be an object but got: %s", err))
|
||||
}
|
||||
|
||||
webAuthnResponse, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(p.Register))
|
||||
if err != nil {
|
||||
return errors.WithStack(herodot.ErrBadRequest().WithReasonf("Unable to parse WebAuthn response: %s", err))
|
||||
}
|
||||
|
||||
web, err := webauthn.New(s.d.Config().WebAuthnConfig(ctx))
|
||||
if err != nil {
|
||||
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to get webAuthn config.").WithDebug(err.Error()))
|
||||
}
|
||||
|
||||
credential, err := web.CreateCredential(webauthnx.NewUser(ctxUpdate.Session.IdentityID[:], nil, web.Config), webAuthnSess, webAuthnResponse)
|
||||
if err != nil {
|
||||
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to create WebAuthn credential: %s", err))
|
||||
}
|
||||
|
||||
i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, ctxUpdate.Session.IdentityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cred := i.GetCredentialsOr(s.ID(), &identity.Credentials{Config: sqlxx.JSONRawMessage("{}")})
|
||||
|
||||
var cc identity.CredentialsWebAuthnConfig
|
||||
if err := json.Unmarshal(cred.Config, &cc); err != nil {
|
||||
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to decode identity credentials.").WithDebug(err.Error()))
|
||||
}
|
||||
|
||||
for k := range cc.Credentials {
|
||||
if bytes.Equal(cc.Credentials[k].ID, credential.ID) {
|
||||
return errors.WithStack(webauthnx.ErrCredentialAlreadyRegistered("#/" + node.WebAuthnRegister))
|
||||
}
|
||||
}
|
||||
|
||||
wc := identity.CredentialFromWebAuthn(credential, s.d.Config().WebAuthnForPasswordless(ctx))
|
||||
wc.AddedAt = time.Now().UTC().Round(time.Second)
|
||||
wc.DisplayName = p.RegisterDisplayName
|
||||
wc.IsPasswordless = s.d.Config().WebAuthnForPasswordless(ctx)
|
||||
cc.UserHandle = ctxUpdate.Session.IdentityID[:]
|
||||
|
||||
cc.Credentials = append(cc.Credentials, *wc)
|
||||
co, err := json.Marshal(cc)
|
||||
if err != nil {
|
||||
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to encode identity credentials.").WithDebug(err.Error()))
|
||||
}
|
||||
|
||||
i.UpsertCredentialsConfig(s.ID(), co, 1)
|
||||
if err := s.validateCredentials(ctx, i); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove the WebAuthn URL from the internal context now that it is set!
|
||||
ctxUpdate.Flow.InternalContext, err = sjson.DeleteBytes(ctxUpdate.Flow.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.d.SettingsFlowPersister().UpdateSettingsFlow(ctx, ctxUpdate.Flow); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aal := identity.AuthenticatorAssuranceLevel1
|
||||
if !s.d.Config().WebAuthnForPasswordless(ctx) {
|
||||
aal = identity.AuthenticatorAssuranceLevel2
|
||||
}
|
||||
|
||||
// Since we added the method, it also means that we have authenticated it
|
||||
if err := s.d.SessionManager().SessionAddAuthenticationMethods(ctx, ctxUpdate.Session.ID, session.AuthenticationMethod{
|
||||
Method: s.ID(),
|
||||
AAL: aal,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctxUpdate.UpdateIdentity(i)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) identityListWebAuthn(id *identity.Identity) (*identity.CredentialsWebAuthnConfig, error) {
|
||||
cred, ok := id.GetCredentials(s.ID())
|
||||
if !ok {
|
||||
return nil, errors.WithStack(sqlcon.ErrNoRows())
|
||||
}
|
||||
|
||||
var cc identity.CredentialsWebAuthnConfig
|
||||
if err := json.Unmarshal(cred.Config, &cc); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return &cc, nil
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateSettingsMethod(ctx context.Context, r *http.Request, id *identity.Identity, f *settings.Flow) (err error) {
|
||||
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.webauthn.Strategy.PopulateSettingsMethod")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
if f.Type != flow.TypeBrowser {
|
||||
return nil
|
||||
}
|
||||
|
||||
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
count, err := s.d.IdentityManager().CountActiveFirstFactorCredentials(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
webAuthns, err := s.identityListWebAuthn(id)
|
||||
if err != nil && !errors.Is(err, sqlcon.ErrNoRows()) {
|
||||
return err
|
||||
}
|
||||
|
||||
var registrationOptions []webauthn.RegistrationOption
|
||||
if webAuthns != nil {
|
||||
exclude := make([]protocol.CredentialDescriptor, 0, len(webAuthns.Credentials))
|
||||
for k := range webAuthns.Credentials {
|
||||
// We only show the option to remove a credential, if it is not the last one when passwordless,
|
||||
// or, if it is for MFA we show it always.
|
||||
cred := &webAuthns.Credentials[k]
|
||||
f.UI.Nodes.Append(webauthnx.NewWebAuthnUnlink(cred, func(a *node.InputAttributes) {
|
||||
// Do not remove this node because it is the last credential the identity can sign in with.
|
||||
a.Disabled = cred.IsPasswordless && count < 2
|
||||
}))
|
||||
exclude = append(exclude, cred.ToWebAuthn().Descriptor())
|
||||
}
|
||||
// Exclude every registered credential, passwordless or not: platform authenticators keep one
|
||||
// credential per relying party and user handle, so re-enrolling would overwrite the entry on
|
||||
// the device while Kratos keeps both server-side (WebAuthn Level 2, section 5.4.3).
|
||||
registrationOptions = append(registrationOptions, webauthn.WithExclusions(exclude))
|
||||
}
|
||||
|
||||
web, err := webauthn.New(s.d.Config().WebAuthnConfig(ctx))
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
option, sessionData, err := web.BeginRegistration(webauthnx.NewUser(id.ID.Bytes(), nil, web.Config), registrationOptions...)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
f.InternalContext, err = sjson.SetBytes(f.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData), sessionData)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
injectWebAuthnOptions, err := json.Marshal(option)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
f.UI.Nodes.Upsert(webauthnx.NewWebAuthnScript(s.d.Config().SelfPublicURL(ctx)))
|
||||
f.UI.Nodes.Upsert(webauthnx.NewWebAuthnConnectionName())
|
||||
f.UI.Nodes.Upsert(webauthnx.NewWebAuthnConnectionTrigger(string(injectWebAuthnOptions)).
|
||||
WithMetaLabel(text.NewInfoSelfServiceSettingsRegisterWebAuthn()))
|
||||
f.UI.Nodes.Upsert(webauthnx.NewWebAuthnConnectionInput())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) handleSettingsError(ctx context.Context, w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithWebAuthnMethod, err error) error {
|
||||
// Do not pause flow if the flow type is an API flow as we can't save cookies in those flows.
|
||||
if e := new(settings.FlowNeedsReAuth); errors.As(err, &e) && ctxUpdate.Flow != nil && ctxUpdate.Flow.Type == flow.TypeBrowser {
|
||||
if _, err := s.d.ContinuityManager().Pause(ctx, w, r, settings.ContinuityKey(s.SettingsStrategyID()), continuity.NewCookieReferenceStore(s.d.ContinuityCookieManager(ctx)), settings.ContinuityOptions(p, ctxUpdate.GetSessionIdentity())...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if ctxUpdate.Flow != nil {
|
||||
ctxUpdate.Flow.UI.ResetMessages()
|
||||
ctxUpdate.Flow.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user