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
317 lines
10 KiB
Go
317 lines
10 KiB
Go
// Copyright © 2023 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package totp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/ory/x/otelx"
|
|
|
|
"github.com/pquerna/otp"
|
|
"github.com/pquerna/otp/totp"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
|
|
"github.com/ory/herodot"
|
|
"github.com/ory/kratos/schema"
|
|
|
|
"github.com/ory/kratos/text"
|
|
|
|
"github.com/ory/kratos/ui/node"
|
|
|
|
"github.com/ory/kratos/session"
|
|
|
|
"github.com/gofrs/uuid"
|
|
"github.com/pkg/errors"
|
|
|
|
"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"
|
|
"github.com/ory/x/decoderx"
|
|
)
|
|
|
|
const InternalContextKeyURL = "url"
|
|
|
|
func (s *Strategy) SettingsStrategyID() string {
|
|
return identity.CredentialsTypeTOTP.String()
|
|
}
|
|
|
|
// Update Settings Flow with TOTP Method
|
|
//
|
|
// swagger:model updateSettingsFlowWithTotpMethod
|
|
type updateSettingsFlowWithTotpMethod struct {
|
|
// ValidationTOTP must contain a valid TOTP based on the
|
|
ValidationTOTP string `json:"totp_code"`
|
|
|
|
// UnlinkTOTP if true will remove the TOTP pairing,
|
|
// effectively removing the credential. This can be used
|
|
// to set up a new TOTP device.
|
|
UnlinkTOTP bool `json:"totp_unlink"`
|
|
|
|
// CSRFToken is the anti-CSRF token
|
|
CSRFToken string `json:"csrf_token"`
|
|
|
|
// Method
|
|
//
|
|
// Should be set to "totp" when trying to add, update, or remove a totp 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 *updateSettingsFlowWithTotpMethod) GetFlowID() uuid.UUID {
|
|
return x.ParseUUID(p.Flow)
|
|
}
|
|
|
|
func (p *updateSettingsFlowWithTotpMethod) 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.totp.Strategy.Settings")
|
|
defer otelx.End(span, &err)
|
|
|
|
var p updateSettingsFlowWithTotpMethod
|
|
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, 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 p.UnlinkTOTP {
|
|
// This is a submit so we need to manually set the type to TOTP
|
|
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 if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.SettingsStrategyID(), s.d); err != nil {
|
|
return ctxUpdate, s.handleSettingsError(ctx, w, r, ctxUpdate, p, err)
|
|
}
|
|
|
|
// This does not come from the payload!
|
|
p.Flow = ctxUpdate.Flow.ID.String()
|
|
if err := s.continueSettingsFlow(ctx, 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, r *http.Request, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithTotpMethod) error {
|
|
if err := flow.MethodEnabledAndAllowed(ctx, flow.SettingsFlow, s.SettingsStrategyID(), p.Method, 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())
|
|
}
|
|
|
|
hasTOTP, err := s.identityHasTOTP(ctx, ctxUpdate.Session.Identity)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// We have now two cases:
|
|
//
|
|
// 1. TOTP should be removed -> we have it already
|
|
// 2. TOTP should be added -> we do not have it yet
|
|
var i *identity.Identity
|
|
if hasTOTP {
|
|
i, err = s.continueSettingsFlowRemoveTOTP(ctx, ctxUpdate, p)
|
|
} else {
|
|
i, err = s.continueSettingsFlowAddTOTP(ctx, ctxUpdate, p)
|
|
}
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctxUpdate.UpdateIdentity(i)
|
|
return nil
|
|
}
|
|
|
|
func (s *Strategy) continueSettingsFlowAddTOTP(ctx context.Context, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithTotpMethod) (*identity.Identity, error) {
|
|
keyURL := gjson.GetBytes(ctxUpdate.Flow.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeyURL)).String()
|
|
if len(keyURL) == 0 {
|
|
return nil, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Could not find the TOTP key in the internal context. This is a code bug and should be reported to https://github.com/ory/kratos/."))
|
|
}
|
|
|
|
key, err := otp.NewKeyFromURL(keyURL)
|
|
if err != nil {
|
|
return nil, errors.WithStack(herodot.ErrInternalServerError().WithTrace(err).WithReasonf("Could not decode TOTP key from the internal context. This is a code bug and should be reported to https://github.com/ory/kratos/."))
|
|
}
|
|
|
|
if len(key.Secret()) == 0 {
|
|
return nil, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("TOTP secret is not set. This is a code bug and should be reported to https://github.com/ory/kratos/."))
|
|
}
|
|
|
|
if p.ValidationTOTP == "" {
|
|
return nil, schema.NewRequiredError("#/totp_code", "totp_code")
|
|
}
|
|
|
|
if !totp.Validate(p.ValidationTOTP, key.Secret()) {
|
|
return nil, schema.NewTOTPVerifierWrongError("#/totp_code")
|
|
}
|
|
|
|
co, err := json.Marshal(&identity.CredentialsTOTPConfig{TOTPURL: key.URL()})
|
|
if err != nil {
|
|
return nil, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to encode totp options to JSON: %s", err))
|
|
}
|
|
|
|
i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, ctxUpdate.Session.Identity.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// We do not really need the identifier, so we add the identity's ID
|
|
c := &identity.Credentials{Type: s.ID(), Identifiers: []string{i.ID.String()}, Config: co}
|
|
c.Config = co
|
|
i.SetCredentials(s.ID(), *c)
|
|
|
|
// Remove the TOTP URL from the internal context now that it is set!
|
|
ctxUpdate.Flow.InternalContext, err = sjson.DeleteBytes(ctxUpdate.Flow.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeyURL))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.d.SettingsFlowPersister().UpdateSettingsFlow(ctx, ctxUpdate.Flow); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 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: identity.AuthenticatorAssuranceLevel2,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return i, nil
|
|
}
|
|
|
|
func (s *Strategy) continueSettingsFlowRemoveTOTP(ctx context.Context, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithTotpMethod) (*identity.Identity, error) {
|
|
if !p.UnlinkTOTP {
|
|
return ctxUpdate.Session.Identity, nil
|
|
}
|
|
|
|
i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, ctxUpdate.Session.Identity.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
i.DeleteCredentialsType(identity.CredentialsTypeTOTP)
|
|
return i, nil
|
|
}
|
|
|
|
func (s *Strategy) identityHasTOTP(ctx context.Context, id *identity.Identity) (bool, error) {
|
|
if len(id.Credentials) == 0 {
|
|
if err := s.d.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, id, identity.ExpandCredentials); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
count, err := s.CountActiveMultiFactorCredentials(ctx, id.Credentials)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return count > 0, 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.totp.Strategy.PopulateSettingsMethod")
|
|
defer otelx.End(span, &err)
|
|
|
|
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
|
|
|
hasTOTP, err := s.identityHasTOTP(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// OTP already set up, just add an unlink option
|
|
if hasTOTP {
|
|
f.UI.Nodes.Upsert(NewUnlinkTOTPNode())
|
|
} else {
|
|
e := NewSchemaExtension(id.ID.String())
|
|
_ = s.d.IdentityValidator().ValidateWithRunner(ctx, id, e)
|
|
|
|
// No TOTP set up yet, add nodes allowing us to add it.
|
|
key, err := NewKey(ctx, e.AccountName, s.d)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
f.InternalContext, err = sjson.SetBytes(f.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeyURL), key.URL())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
qr, err := NewTOTPImageQRNode(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
f.UI.Nodes.Upsert(NewTOTPSourceURLNode(key))
|
|
f.UI.Nodes.Upsert(qr)
|
|
f.UI.Nodes.Upsert(NewVerifyTOTPNode())
|
|
f.UI.Nodes.Append(node.NewInputField("method", "totp", node.TOTPGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoNodeLabelSave()))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Strategy) handleSettingsError(ctx context.Context, w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithTotpMethod, 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
|
|
}
|