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

This commit is contained in:
upstream-mirror
2026-09-15 21:19:12 +05:00
commit c60cc7ca75
8092 changed files with 394160 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
{
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/totp/login.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": [
"totp_code",
"method"
],
"properties": {
"csrf_token": {
"type": "string"
},
"method": {
"type": "string"
},
"totp_code": {
"type": "string",
"minLength": 6
},
"transient_payload": {
"type": "object",
"additionalProperties": true
}
}
}

View File

@@ -0,0 +1,37 @@
{
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/totp/settings.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"csrf_token": {
"type": "string"
},
"method": {
"type": "string"
},
"totp_code": {
"type": "string"
},
"totp_unlink": {
"type": "boolean"
},
"transient_payload": {
"type": "object",
"additionalProperties": true
}
},
"if": {
"properties": {
"method": {
"const": "totp"
}
}
},
"then": {
"totp_code": {
"type": "string",
"maxLength": 6,
"minLength": 6
}
}
}

View File

@@ -0,0 +1,54 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "totp_code",
"node_type": "input",
"required": true,
"type": "text",
"value": ""
},
"group": "totp",
"messages": [],
"meta": {
"label": {
"id": 1010006,
"text": "Authentication code",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "method",
"node_type": "input",
"type": "submit",
"value": "totp"
},
"group": "totp",
"messages": [],
"meta": {
"label": {
"id": 1010009,
"text": "Use Authenticator",
"type": "info"
}
},
"type": "input"
}
]

View File

@@ -0,0 +1,93 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"height": 256,
"id": "totp_qr",
"node_type": "img",
"width": 256
},
"group": "totp",
"messages": [],
"meta": {
"label": {
"id": 1050005,
"text": "Authenticator app QR code",
"type": "info"
}
},
"type": "img"
},
{
"attributes": {
"id": "totp_secret_key",
"node_type": "text",
"text": {
"context": {
},
"id": 1050006,
"type": "info"
}
},
"group": "totp",
"messages": [],
"meta": {
"label": {
"id": 1050017,
"text": "This is your authenticator app secret. Use it if you can not scan the QR code.",
"type": "info"
}
},
"type": "text"
},
{
"attributes": {
"disabled": false,
"name": "totp_code",
"node_type": "input",
"required": true,
"type": "text"
},
"group": "totp",
"messages": [],
"meta": {
"label": {
"id": 1070006,
"text": "Verify code",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "method",
"node_type": "input",
"type": "submit",
"value": "totp"
},
"group": "totp",
"messages": [],
"meta": {
"label": {
"id": 1070003,
"text": "Save",
"type": "info"
}
},
"type": "input"
}
]

View File

@@ -0,0 +1,35 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "totp_unlink",
"node_type": "input",
"required": true,
"type": "submit",
"value": "true"
},
"group": "totp",
"messages": [],
"meta": {
"label": {
"id": 1050004,
"text": "Unlink TOTP Authenticator App",
"type": "info"
}
},
"type": "input"
}
]

View File

@@ -0,0 +1,54 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "totp",
"attributes": {
"name": "totp_code",
"type": "text",
"value": "",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010006,
"text": "Authentication code",
"type": "info"
}
}
},
{
"type": "input",
"group": "totp",
"attributes": {
"name": "method",
"type": "submit",
"value": "totp",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010009,
"text": "Use Authenticator",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,54 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "totp",
"attributes": {
"name": "totp_code",
"type": "text",
"value": "",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010006,
"text": "Authentication code",
"type": "info"
}
}
},
{
"type": "input",
"group": "totp",
"attributes": {
"name": "method",
"type": "submit",
"value": "totp",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010009,
"text": "Use Authenticator",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,59 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package totp
import (
"bytes"
"context"
"encoding/base64"
"image/png"
"github.com/pkg/errors"
"github.com/pquerna/otp"
stdtotp "github.com/pquerna/otp/totp"
"github.com/ory/kratos/driver/config"
)
// rfc4226 recommends:
//
// The algorithm MUST use a strong shared secret. The length of
// the shared secret MUST be at least 128 bits. This document
// RECOMMENDs a shared secret length of 160 bits.
//
// So we need 160/8 = 20 key length. stdtotp.Generate uses the key
// length for reading from crypto.Rand.
const secretSize = 160 / 8
const digits = otp.DigitsSix
func NewKey(ctx context.Context, accountName string, d interface {
config.Provider
}) (*otp.Key, error) {
key, err := stdtotp.Generate(stdtotp.GenerateOpts{
Issuer: d.Config().TOTPIssuer(ctx),
AccountName: accountName,
SecretSize: secretSize,
Digits: digits,
Period: 30,
})
if err != nil {
return nil, errors.WithStack(err)
}
return key, err
}
func KeyToHTMLImage(key *otp.Key) (string, error) {
var buf bytes.Buffer
img, err := key.Image(256, 256)
if err != nil {
return "", errors.WithStack(err)
}
if err := png.Encode(&buf, img); err != nil {
return "", errors.WithStack(err)
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}

View File

@@ -0,0 +1,46 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package totp_test
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/x/contextx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/selfservice/strategy/totp"
)
func TestGenerator(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
t.Run("no issuer set", func(t *testing.T) {
key, err := totp.NewKey(t.Context(), "foo", reg)
require.NoError(t, err)
assert.Equal(t, reg.Config().SelfPublicURL(t.Context()).Hostname(), key.Issuer(), "if issuer is not set explicitly it should be the public URL")
})
t.Run("custom issuer set", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyTOTPIssuer, "foobar.com")
key, err := totp.NewKey(ctx, "foo", reg)
require.NoError(t, err)
assert.Equal(t, "foobar.com", key.Issuer(), "if issuer is set explicitly it should be the correct value")
assert.Equal(t, "foo", key.AccountName())
})
t.Run("generate HTML image", func(t *testing.T) {
key, err := totp.NewKey(t.Context(), "foo", reg)
require.NoError(t, err)
img, err := totp.KeyToHTMLImage(key)
require.NoError(t, err)
assert.Truef(t, strings.HasPrefix(img, "data:image/png;base64,"), "image is a base64 encoded png: %s", img)
})
}

View File

@@ -0,0 +1,145 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package totp
import (
"encoding/json"
"net/http"
"github.com/pkg/errors"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"go.opentelemetry.io/otel/attribute"
"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/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/x/decoderx"
"github.com/ory/x/otelx"
)
func (s *Strategy) PopulateLoginMethod(r *http.Request, requestedAAL identity.AuthenticatorAssuranceLevel, sr *login.Flow) error {
// This strategy can only solve AAL2
if requestedAAL != identity.AuthenticatorAssuranceLevel2 {
return nil
}
// We have done proper validation before so this should never error.
// Only the identity ID is read below; the identity is re-fetched with its confidential
// fields anyway, so skip expanding anything here.
sess, err := s.d.SessionManager().FetchFromRequest(r.Context(), r, session.ExpandNothing, identity.ExpandNothing)
if err != nil {
return err
}
id, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), sess.IdentityID)
if err != nil {
return err
}
_, ok := id.GetCredentials(s.ID())
if !ok {
// Identity has no TOTP
return nil
}
sr.UI.SetCSRF(s.d.GenerateCSRFToken(r))
sr.UI.SetNode(node.NewInputField("totp_code", "", node.TOTPGroup, node.InputAttributeTypeText, node.WithRequiredInputAttribute).WithMetaLabel(text.NewInfoLoginTOTPLabel()))
sr.UI.GetNodes().Append(node.NewInputField("method", s.ID(), node.TOTPGroup, node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoLoginTOTP()))
return nil
}
func (s *Strategy) handleLoginError(r *http.Request, f *login.Flow, err error) error {
if f != nil {
f.UI.Nodes.ResetNodes("totp_code")
if f.Type == flow.TypeBrowser {
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
}
}
return err
}
// Update Login Flow with TOTP Method
//
// swagger:model updateLoginFlowWithTotpMethod
type updateLoginFlowWithTotpMethod struct {
// Method should be set to "totp" when logging in using the TOTP strategy.
//
// required: true
Method string `json:"method"`
// Sending the anti-csrf token is only required for browser login flows.
CSRFToken string `json:"csrf_token"`
// The TOTP code.
//
// required: true
TOTPCode string `json:"totp_code"`
// Transient data to pass along to any webhooks
//
// required: false
TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"`
}
func (s *Strategy) Login(_ 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.totp.Strategy.Login")
defer otelx.End(span, &err)
if err := login.CheckAAL(f, identity.AuthenticatorAssuranceLevel2); err != nil {
span.SetAttributes(attribute.String("not_responsible_reason", "requested AAL is not AAL2"))
return nil, err
}
if err := flow.MethodEnabledAndAllowedFromRequest(r, f.GetFlowName(), s.ID().String(), s.d); err != nil {
return nil, err
}
var p updateLoginFlowWithTotpMethod
if err := decoderx.Decode(r, &p,
decoderx.HTTPDecoderSetValidatePayloads(true),
decoderx.MustHTTPRawJSONSchemaCompiler(loginSchema),
decoderx.HTTPDecoderJSONFollowsFormFormat()); err != nil {
return nil, s.handleLoginError(r, f, err)
}
f.TransientPayload = p.TransientPayload
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)
}
i, c, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, s.ID(), sess.IdentityID.String())
if err != nil {
return nil, s.handleLoginError(r, f, errors.WithStack(schema.NewNoTOTPDeviceRegistered()))
}
var o identity.CredentialsTOTPConfig
if err := json.Unmarshal(c.Config, &o); err != nil {
return nil, x.WrapWithIdentityIDError(errors.WithStack(herodot.ErrInternalServerError().WithReason("The TOTP credentials could not be decoded properly").WithDebug(err.Error()).WithWrap(err)), i.ID)
}
key, err := otp.NewKeyFromURL(o.TOTPURL)
if err != nil {
return nil, s.handleLoginError(r, f, x.WrapWithIdentityIDError(errors.WithStack(err), i.ID))
}
if !totp.Validate(p.TOTPCode, key.Secret()) {
return nil, s.handleLoginError(r, f, x.WrapWithIdentityIDError(errors.WithStack(schema.NewTOTPVerifierWrongError("#/")), i.ID))
}
f.Active = s.ID()
if err = s.d.LoginFlowPersister().UpdateLoginFlow(ctx, f); err != nil {
return nil, s.handleLoginError(r, f, x.WrapWithIdentityIDError(errors.WithStack(herodot.ErrInternalServerError().WithReason("Could not update flow").WithDebug(err.Error())), i.ID))
}
return i, nil
}

View File

@@ -0,0 +1,650 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package totp_test
import (
"bytes"
"context"
_ "embed"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/pquerna/otp"
stdtotp "github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/driver"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/strategy/totp"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/assertx"
"github.com/ory/x/configx"
"github.com/ory/x/httprouterx"
"github.com/ory/x/snapshotx"
"github.com/ory/x/sqlxx"
)
const totpCodeGJSONQuery = "ui.nodes.#(attributes.name==totp_code)"
func createIdentityWithoutTOTP(ctx context.Context, t *testing.T, reg driver.Registry) *identity.Identity {
id, _, _ := createIdentity(ctx, t, reg)
delete(id.Credentials, identity.CredentialsTypeTOTP)
require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(ctx, id))
return id
}
func createIdentity(ctx context.Context, t *testing.T, reg driver.Registry) (*identity.Identity, string, *otp.Key) {
identifier := x.NewUUID().String() + "@ory.sh"
password := x.NewUUID().String()
key, err := totp.NewKey(ctx, "foo", reg)
require.NoError(t, err)
p, err := reg.Hasher(ctx).Generate(ctx, []byte(password))
require.NoError(t, err)
i := &identity.Identity{
Traits: identity.Traits(fmt.Sprintf(`{"subject":"%s"}`, identifier)),
VerifiableAddresses: []identity.VerifiableAddress{
{
Value: identifier,
Verified: false,
CreatedAt: time.Now(),
},
},
}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i))
i.Credentials = map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {
Type: identity.CredentialsTypePassword,
Identifiers: []string{identifier},
Config: sqlxx.JSONRawMessage(`{"hashed_password":"` + string(p) + `"}`),
},
identity.CredentialsTypeTOTP: {
Type: identity.CredentialsTypeTOTP,
Identifiers: []string{i.ID.String()},
Config: sqlxx.JSONRawMessage(`{"totp_url":"` + string(key.URL()) + `"}`),
},
}
require.NoError(t, i.SetAvailableAAL(ctx, reg.IdentityManager()))
require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(ctx, i))
return i, password, key
}
// createIdentityWithImportedTOTPViaCreate mimics the admin identity import
// path (`POST /admin/identities` with `credentials.totp`): it builds an
// Identity with TOTP and password credentials populated upfront, leaves
// `Identifiers` empty on the TOTP credential exactly like
// `Handler.importTOTPCredentials` does, and persists it via
// `Manager.Create`. The persister is expected to default the TOTP
// identifier to the persisted identity ID. See
// https://github.com/ory/kratos/issues/4561.
func createIdentityWithImportedTOTPViaCreate(ctx context.Context, t *testing.T, reg driver.Registry) (*identity.Identity, *otp.Key) {
identifier := x.NewUUID().String() + "@ory.sh"
password := x.NewUUID().String()
key, err := totp.NewKey(ctx, "foo", reg)
require.NoError(t, err)
p, err := reg.Hasher(ctx).Generate(ctx, []byte(password))
require.NoError(t, err)
i := &identity.Identity{
Traits: identity.Traits(fmt.Sprintf(`{"subject":"%s"}`, identifier)),
VerifiableAddresses: []identity.VerifiableAddress{
{Value: identifier, Verified: false, CreatedAt: time.Now()},
},
Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {
Type: identity.CredentialsTypePassword,
Identifiers: []string{identifier},
Config: sqlxx.JSONRawMessage(`{"hashed_password":"` + string(p) + `"}`),
},
identity.CredentialsTypeTOTP: {
// Identifiers intentionally empty; the import path does not
// know the persisted identity ID yet.
Type: identity.CredentialsTypeTOTP,
Config: sqlxx.JSONRawMessage(`{"totp_url":"` + string(key.URL()) + `"}`),
},
},
}
require.NoError(t, i.SetAvailableAAL(ctx, reg.IdentityManager()))
require.NoError(t, reg.IdentityManager().Create(ctx, i))
return i, key
}
// createIdentityWithImportedTOTPViaUpdate mimics the admin identity import
// path on an existing identity (`PUT /admin/identities/{id}` with
// `credentials.totp`): it first creates an identity with a password
// credential, then attaches a TOTP credential via `Manager.Update` with
// `Identifiers` left empty. The persister is expected to default the TOTP
// identifier to the existing identity ID. See
// https://github.com/ory/kratos/issues/4561.
func createIdentityWithImportedTOTPViaUpdate(ctx context.Context, t *testing.T, reg driver.Registry) (*identity.Identity, *otp.Key) {
identifier := x.NewUUID().String() + "@ory.sh"
password := x.NewUUID().String()
p, err := reg.Hasher(ctx).Generate(ctx, []byte(password))
require.NoError(t, err)
i := &identity.Identity{
Traits: identity.Traits(fmt.Sprintf(`{"subject":"%s"}`, identifier)),
VerifiableAddresses: []identity.VerifiableAddress{
{Value: identifier, Verified: false, CreatedAt: time.Now()},
},
Credentials: map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {
Type: identity.CredentialsTypePassword,
Identifiers: []string{identifier},
Config: sqlxx.JSONRawMessage(`{"hashed_password":"` + string(p) + `"}`),
},
},
}
require.NoError(t, i.SetAvailableAAL(ctx, reg.IdentityManager()))
require.NoError(t, reg.IdentityManager().Create(ctx, i))
key, err := totp.NewKey(ctx, "foo", reg)
require.NoError(t, err)
// Import a TOTP credential via update with no identifier set; the
// persister must default it to the (already persisted) identity ID.
i.Credentials[identity.CredentialsTypeTOTP] = identity.Credentials{
Type: identity.CredentialsTypeTOTP,
Config: sqlxx.JSONRawMessage(`{"totp_url":"` + string(key.URL()) + `"}`),
}
require.NoError(t, i.SetAvailableAAL(ctx, reg.IdentityManager()))
require.NoError(t, reg.IdentityManager().Update(ctx, i, identity.ManagerAllowWriteProtectedTraits))
return i, key
}
func TestCompleteLogin(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypePassword, true)),
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypeTOTP, true)),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/login.schema.json")),
)
redirTS := testhelpers.NewRedirSessionEchoTS(t, reg)
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{redirTS.URL + "/return-to-wherever"})
router := httprouterx.NewRouterPublic()
publicTS, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, httprouterx.NewRouterAdminWithPrefix())
errTS := testhelpers.NewErrorTestServer(t, reg)
uiTS := testhelpers.NewLoginUIFlowEchoServer(t, reg)
t.Run("case=totp payload is set when identity has totp", func(t *testing.T) {
id, _, _ := createIdentity(t.Context(), t, reg)
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
f := testhelpers.InitializeLoginFlowViaAPICtx(context.Background(), t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{
"0.attributes.value",
})
})
t.Run("case=totp payload is not set when identity has no totp", func(t *testing.T) {
id := createIdentityWithoutTOTP(t.Context(), t, reg)
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
f := testhelpers.InitializeLoginFlowViaAPICtx(context.Background(), t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
assertx.EqualAsJSON(t, nil, f.Ui.Nodes)
})
t.Run("case=should show the error ui because the request payload is malformed", func(t *testing.T) {
id, _, _ := createIdentity(t.Context(), t, reg)
t.Run("type=api", func(t *testing.T) {
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
f := testhelpers.InitializeLoginFlowViaAPICtx(t.Context(), t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
body, res := testhelpers.LoginMakeRequestCtx(t.Context(), t, true, false, f, apiClient, "14=)=!(%)$/ZP()GHIÖ")
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
assert.Contains(t, body, `Expected JSON sent in request body to be an object but got: Number`)
})
t.Run("type=browser", func(t *testing.T) {
browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, reg, id)
f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, false, false, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
body, res := testhelpers.LoginMakeRequest(t, false, false, f, browserClient, "14=)=!(%)$/ZP()GHIÖ")
assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/login-ts")
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "invalid URL escape", "%s", body)
})
t.Run("type=spa", func(t *testing.T) {
browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, reg, id)
f := testhelpers.InitializeLoginFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, false, true, false, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
body, res := testhelpers.LoginMakeRequestCtx(t.Context(), t, false, true, f, browserClient, "14=)=!(%)$/ZP()GHIÖ")
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "invalid URL escape", "%s", body)
})
})
doAPIFlow := func(t *testing.T, v func(url.Values), id *identity.Identity) (string, *http.Response) {
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id)
f := testhelpers.InitializeLoginFlowViaAPICtx(t.Context(), t, apiClient, publicTS, false, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2))
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("method", "totp")
v(values)
payload := testhelpers.EncodeFormAsJSON(t, true, values)
return testhelpers.LoginMakeRequestCtx(t.Context(), t, true, false, f, apiClient, payload)
}
doBrowserFlow := func(t *testing.T, spa bool, v func(url.Values), id *identity.Identity, returnTo string) (string, *http.Response) {
browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, reg, id)
opts := []testhelpers.InitFlowWithOption{testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel2)}
if len(returnTo) > 0 {
opts = append(opts, testhelpers.InitFlowWithReturnTo(returnTo))
}
f := testhelpers.InitializeLoginFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, false, spa, false, false, opts...)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("method", "totp")
v(values)
return testhelpers.LoginMakeRequestCtx(t.Context(), t, false, spa, f, browserClient, values.Encode())
}
checkURL := func(t *testing.T, shouldRedirect bool, res *http.Response) {
if shouldRedirect {
assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/login-ts")
} else {
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
}
}
t.Run("case=should fail if code is empty", func(t *testing.T) {
id, _, _ := createIdentity(t.Context(), t, reg)
payload := func(v url.Values) {
v.Set("totp_code", "")
}
check := func(t *testing.T, shouldRedirect bool, body string, res *http.Response) {
checkURL(t, shouldRedirect, res)
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
assert.Equal(t, "Property totp_code is missing.", gjson.Get(body, totpCodeGJSONQuery+".messages.0.text").String(), "%s", body)
}
t.Run("type=api", func(t *testing.T) {
body, res := doAPIFlow(t, payload, id)
check(t, false, body, res)
})
t.Run("type=browser", func(t *testing.T) {
body, res := doBrowserFlow(t, false, payload, id, "")
check(t, true, body, res)
})
t.Run("type=spa", func(t *testing.T) {
body, res := doBrowserFlow(t, true, payload, id, "")
check(t, false, body, res)
})
})
t.Run("case=should fail if code is invalid", func(t *testing.T) {
id, _, _ := createIdentity(t.Context(), t, reg)
payload := func(v url.Values) {
v.Set("totp_code", "111111")
}
check := func(t *testing.T, shouldRedirect bool, body string, res *http.Response) {
checkURL(t, shouldRedirect, res)
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
assert.Equal(t, text.NewErrorValidationTOTPVerifierWrong().Text, gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
}
t.Run("type=api", func(t *testing.T) {
body, res := doAPIFlow(t, payload, id)
check(t, false, body, res)
})
t.Run("type=browser", func(t *testing.T) {
body, res := doBrowserFlow(t, false, payload, id, "")
check(t, true, body, res)
})
t.Run("type=spa", func(t *testing.T) {
body, res := doBrowserFlow(t, true, payload, id, "")
check(t, false, body, res)
})
})
t.Run("case=should fail if code is too long", func(t *testing.T) {
id, _, _ := createIdentity(t.Context(), t, reg)
payload := func(v url.Values) {
v.Set("totp_code", "1111111111")
}
check := func(t *testing.T, shouldRedirect bool, body string, res *http.Response) {
checkURL(t, shouldRedirect, res)
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
assert.Equal(t, text.NewErrorValidationTOTPVerifierWrong().Text, gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
}
t.Run("type=api", func(t *testing.T) {
body, res := doAPIFlow(t, payload, id)
check(t, false, body, res)
})
t.Run("type=browser", func(t *testing.T) {
body, res := doBrowserFlow(t, false, payload, id, "")
check(t, true, body, res)
})
t.Run("type=spa", func(t *testing.T) {
body, res := doBrowserFlow(t, true, payload, id, "")
check(t, false, body, res)
})
})
t.Run("case=should fail if TOTP was not set up for identity", func(t *testing.T) {
id := createIdentityWithoutTOTP(t.Context(), t, reg)
payload := func(v url.Values) {
v.Set("totp_code", "111111")
}
check := func(t *testing.T, shouldRedirect bool, body string, res *http.Response) {
checkURL(t, shouldRedirect, res)
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
assert.Equal(t, text.NewErrorValidationNoTOTPDevice().Text, gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
}
t.Run("type=api", func(t *testing.T) {
body, res := doAPIFlow(t, payload, id)
check(t, false, body, res)
})
t.Run("type=browser", func(t *testing.T) {
body, res := doBrowserFlow(t, false, payload, id, "")
check(t, true, body, res)
})
t.Run("type=spa", func(t *testing.T) {
body, res := doBrowserFlow(t, true, payload, id, "")
check(t, false, body, res)
})
})
t.Run("case=should pass when TOTP is supplied correctly", func(t *testing.T) {
id, _, key := createIdentity(t.Context(), t, reg)
code, err := stdtotp.GenerateCode(key.Secret(), time.Now())
require.NoError(t, err)
payload := func(v url.Values) {
v.Set("totp_code", code)
}
startAt := time.Now()
check := func(t *testing.T, shouldRedirect bool, body string, res *http.Response) {
prefix := "session."
if shouldRedirect {
assert.Contains(t, res.Request.URL.String(), redirTS.URL+"/return-ts")
prefix = ""
} else {
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
}
assert.True(t, gjson.Get(body, prefix+"active").Bool(), "%s", body)
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, gjson.Get(body, prefix+"authenticator_assurance_level").String())
require.Len(t, gjson.Get(body, prefix+"authentication_methods").Array(), 2)
assert.EqualValues(t, identity.CredentialsTypePassword, gjson.Get(body, prefix+"authentication_methods.0.method").String(), 2)
assert.True(t, gjson.Get(body, prefix+"authentication_methods.0.completed_at").Time().After(startAt), 2)
assert.EqualValues(t, identity.CredentialsTypeTOTP, gjson.Get(body, prefix+"authentication_methods.1.method").String(), 2)
assert.True(t, gjson.Get(body, prefix+"authentication_methods.1.completed_at").Time().After(startAt), 2)
assert.True(t, gjson.Get(body, prefix+"authentication_methods.1.completed_at").Time().After(gjson.Get(body, prefix+"authentication_methods.0.completed_at").Time()), 2)
assert.Equal(t, gjson.Get(body, prefix+"authentication_methods.1.completed_at").Time().Unix(), gjson.Get(body, prefix+"authenticated_at").Time().Unix(), "%s", body)
}
t.Run("type=api", func(t *testing.T) {
body, res := doAPIFlow(t, payload, id)
check(t, false, body, res)
assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body)
})
t.Run("type=browser", func(t *testing.T) {
body, res := doBrowserFlow(t, false, payload, id, "")
check(t, true, body, res)
assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body)
})
t.Run("type=browser set return_to", func(t *testing.T) {
returnTo := redirTS.URL + "/return-to-wherever"
body, res := doBrowserFlow(t, false, payload, id, returnTo)
t.Log(res.Request.URL.String())
assert.Contains(t, res.Request.URL.String(), returnTo)
assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body)
})
t.Run("type=spa", func(t *testing.T) {
body, res := doBrowserFlow(t, true, payload, id, "")
check(t, false, body, res)
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body)
assert.EqualValues(t, conf.SelfServiceBrowserDefaultReturnTo(ctx).String(), gjson.Get(body, "continue_with.0.redirect_browser_to").String(), "%s", body)
})
t.Run("type=spa set return_to", func(t *testing.T) {
returnTo := redirTS.URL + "/return-to-wherever"
body, res := doBrowserFlow(t, true, payload, id, returnTo)
check(t, false, body, res)
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body)
assert.EqualValues(t, returnTo, gjson.Get(body, "continue_with.0.redirect_browser_to").String(), "%s", body)
})
})
// Regression coverage for https://github.com/ory/kratos/issues/4561.
// When a TOTP credential is imported through the admin identity API,
// AAL2 login must be able to resolve it via FindByCredentialsIdentifier,
// which means an identity_credential_identifiers row keyed by the
// identity ID has to be persisted. Both the create and update entry
// points are covered here.
t.Run("case=imported TOTP via create identity should succeed at AAL2 login", func(t *testing.T) {
id, key := createIdentityWithImportedTOTPViaCreate(t.Context(), t, reg)
// The persister must have defaulted the TOTP identifier to the
// identity ID, otherwise the login lookup below would fail with
// "You have no TOTP device set up.".
actual, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), id.ID)
require.NoError(t, err)
assert.Equal(t, []string{actual.ID.String()}, actual.Credentials[identity.CredentialsTypeTOTP].Identifiers)
code, err := stdtotp.GenerateCode(key.Secret(), time.Now())
require.NoError(t, err)
payload := func(v url.Values) {
v.Set("totp_code", code)
}
startAt := time.Now()
check := func(t *testing.T, body string) {
assert.True(t, gjson.Get(body, "session.active").Bool(), "%s", body)
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, gjson.Get(body, "session.authenticator_assurance_level").String())
// The seeded session records password at AAL1; submitting the
// TOTP code adds a second authentication method.
require.Len(t, gjson.Get(body, "session.authentication_methods").Array(), 2, "%s", body)
assert.EqualValues(t, identity.CredentialsTypePassword, gjson.Get(body, "session.authentication_methods.0.method").String(), "%s", body)
assert.EqualValues(t, identity.CredentialsTypeTOTP, gjson.Get(body, "session.authentication_methods.1.method").String(), "%s", body)
assert.True(t, gjson.Get(body, "session.authentication_methods.1.completed_at").Time().After(startAt), "%s", body)
}
t.Run("type=api", func(t *testing.T) {
body, res := doAPIFlow(t, payload, id)
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
check(t, body)
})
})
t.Run("case=imported TOTP via update identity should succeed at AAL2 login", func(t *testing.T) {
id, key := createIdentityWithImportedTOTPViaUpdate(t.Context(), t, reg)
actual, err := reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), id.ID)
require.NoError(t, err)
assert.Equal(t, []string{actual.ID.String()}, actual.Credentials[identity.CredentialsTypeTOTP].Identifiers)
code, err := stdtotp.GenerateCode(key.Secret(), time.Now())
require.NoError(t, err)
payload := func(v url.Values) {
v.Set("totp_code", code)
}
startAt := time.Now()
check := func(t *testing.T, body string) {
assert.True(t, gjson.Get(body, "session.active").Bool(), "%s", body)
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, gjson.Get(body, "session.authenticator_assurance_level").String())
// The seeded session records password at AAL1; submitting the
// TOTP code adds a second authentication method.
require.Len(t, gjson.Get(body, "session.authentication_methods").Array(), 2, "%s", body)
assert.EqualValues(t, identity.CredentialsTypePassword, gjson.Get(body, "session.authentication_methods.0.method").String(), "%s", body)
assert.EqualValues(t, identity.CredentialsTypeTOTP, gjson.Get(body, "session.authentication_methods.1.method").String(), "%s", body)
assert.True(t, gjson.Get(body, "session.authentication_methods.1.completed_at").Time().After(startAt), "%s", body)
}
t.Run("type=api", func(t *testing.T) {
body, res := doAPIFlow(t, payload, id)
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
check(t, body)
})
})
t.Run("case=should fail because totp can not handle AAL1", func(t *testing.T) {
apiClient := testhelpers.NewDebugClient(t)
f := testhelpers.InitializeLoginFlowViaAPICtx(t.Context(), t, apiClient, publicTS, false)
update, err := reg.LoginFlowPersister().GetLoginFlow(t.Context(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
update.RequestedAAL = identity.AuthenticatorAssuranceLevel1
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), update))
req, err := http.NewRequest("POST", f.Ui.Action, bytes.NewBufferString(`{"method":"totp"}`))
require.NoError(t, err)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
res, err := testhelpers.NewTestClient(t).Do(req)
require.NoError(t, err)
body := x.MustReadAll(res.Body)
require.NoError(t, res.Body.Close())
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
assert.Equal(t, text.NewErrorValidationLoginNoStrategyFound().Text, gjson.GetBytes(body, "ui.messages.0.text").String())
})
t.Run("case=should pass without csrf if API flow", func(t *testing.T) {
id, _, _ := createIdentity(t.Context(), t, reg)
body, res := doAPIFlow(t, func(v url.Values) {
v.Del("csrf_token")
v.Set("totp_code", "111111")
}, id)
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
assert.Equal(t, text.NewErrorValidationTOTPVerifierWrong().Text, gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
})
t.Run("case=should fail if CSRF token is invalid", func(t *testing.T) {
id, _, _ := createIdentity(t.Context(), t, reg)
t.Run("type=browser", func(t *testing.T) {
body, res := doBrowserFlow(t, false, func(v url.Values) {
v.Del("csrf_token")
v.Set("totp_code", "111111")
}, id, "")
assert.Contains(t, res.Request.URL.String(), errTS.URL)
assert.Equal(t, nosurfx.ErrInvalidCSRFToken().Reason(), gjson.Get(body, "reason").String(), body)
})
t.Run("type=spa", func(t *testing.T) {
body, res := doBrowserFlow(t, true, func(v url.Values) {
v.Del("csrf_token")
v.Set("totp_code", "111111")
}, id, "")
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
assert.Equal(t, nosurfx.ErrInvalidCSRFToken().Reason(), gjson.Get(body, "error.reason").String(), body)
})
})
t.Run("case=should pass return_to URL after login", func(t *testing.T) {
id, pwd, _ := createIdentity(t.Context(), t, reg)
t.Run("type=browser", func(t *testing.T) {
returnTo := redirTS.URL + "/return-to-wherever"
browserClient := testhelpers.NewClientWithCookies(t)
f := testhelpers.InitializeLoginFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, false, false, false, false, testhelpers.InitFlowWithReturnTo(returnTo))
cred, ok := id.GetCredentials(identity.CredentialsTypePassword)
require.True(t, ok)
values := url.Values{
"method": {"password"}, "password_identifier": {cred.Identifiers[0]},
"password": {pwd}, "csrf_token": {nosurfx.FakeCSRFToken},
}.Encode()
body, res := testhelpers.LoginMakeRequestCtx(t.Context(), t, false, false, f, browserClient, values)
require.Contains(t, res.Request.URL.Path, "login", "%s", res.Request.URL.String())
assert.Equal(t, gjson.Get(body, "requested_aal").String(), "aal2", "%s", body)
assert.Equal(t, gjson.Get(body, "return_to").String(), returnTo, "%s", body)
})
})
}
func TestFormHydration(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypeTOTP, true)),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/login.schema.json")),
)
s, err := reg.AllLoginStrategies().Strategy(identity.CredentialsTypeTOTP)
require.NoError(t, err)
fh, ok := s.(login.AAL2FormHydrator)
require.True(t, ok)
toSnapshot := func(t *testing.T, f *login.Flow) {
t.Helper()
// The CSRF token has a unique value that messes with the snapshot - ignore it.
f.UI.Nodes.ResetNodes("csrf_token")
snapshotx.SnapshotT(t, f.UI.Nodes)
}
newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *login.Flow) {
r := httptest.NewRequest("GET", "/self-service/login/browser", nil)
r = r.WithContext(ctx)
t.Helper()
f, err := login.NewFlow(reg, r, flow.TypeBrowser)
f.UI.Nodes = make(node.Nodes, 0)
require.NoError(t, err)
return r, f
}
t.Run("method=PopulateLoginMethodSecondFactor", func(t *testing.T) {
id, _, _ := createIdentity(t.Context(), t, reg)
headers := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader()
r, f := newFlow(t.Context(), t)
r.Header = headers
f.RequestedAAL = identity.AuthenticatorAssuranceLevel2
require.NoError(t, fh.PopulateLoginMethodSecondFactor(r, f))
toSnapshot(t, f)
})
t.Run("method=PopulateLoginMethodSecondFactorRefresh", func(t *testing.T) {
id, _, _ := createIdentity(t.Context(), t, reg)
headers := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader()
r, f := newFlow(t.Context(), t)
r.Header = headers
f.RequestedAAL = identity.AuthenticatorAssuranceLevel2
require.NoError(t, fh.PopulateLoginMethodSecondFactorRefresh(r, f))
toSnapshot(t, f)
})
}

View File

@@ -0,0 +1,43 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package totp
import (
"github.com/pquerna/otp"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
)
func NewVerifyTOTPNode() *node.Node {
return node.NewInputField(node.TOTPCode, nil, node.TOTPGroup,
node.InputAttributeTypeText,
node.WithRequiredInputAttribute).
WithMetaLabel(text.NewInfoNodeLabelVerifyOTP())
}
func NewTOTPImageQRNode(key *otp.Key) (*node.Node, error) {
src, err := KeyToHTMLImage(key)
if err != nil {
return nil, err
}
return node.NewImageField(node.TOTPQR, src, node.TOTPGroup, node.WithImageAttributes(func(a *node.ImageAttributes) {
a.Height = 256
a.Width = 256
})).WithMetaLabel(text.NewInfoSelfServiceSettingsTOTPQRCode()), nil
}
func NewTOTPSourceURLNode(key *otp.Key) *node.Node {
return node.NewTextField(node.TOTPSecretKey,
text.NewInfoSelfServiceSettingsTOTPSecret(key.Secret()), node.TOTPGroup).
WithMetaLabel(text.NewInfoSelfServiceSettingsTOTPSecretLabel())
}
func NewUnlinkTOTPNode() *node.Node {
return node.NewInputField(node.TOTPUnlink, "true", node.TOTPGroup,
node.InputAttributeTypeSubmit,
node.WithRequiredInputAttribute).
WithMetaLabel(text.NewInfoSelfServiceSettingsUpdateUnlinkTOTP())
}

View File

@@ -0,0 +1,14 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package totp
import (
_ "embed"
)
//go:embed .schema/settings.schema.json
var settingsSchema []byte
//go:embed .schema/login.schema.json
var loginSchema []byte

View File

@@ -0,0 +1,34 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package totp
import (
"fmt"
"sync"
"github.com/ory/jsonschema/v3"
"github.com/ory/kratos/schema"
)
type SchemaExtension struct {
AccountName string
l sync.Mutex
}
func NewSchemaExtension(fallback string) *SchemaExtension {
return &SchemaExtension{AccountName: fallback}
}
func (r *SchemaExtension) Run(_ jsonschema.ValidationContext, s schema.ExtensionConfig, value interface{}) error {
r.l.Lock()
defer r.l.Unlock()
if s.Credentials.TOTP.AccountName {
r.AccountName = fmt.Sprintf("%s", value)
}
return nil
}
func (r *SchemaExtension) Finish() error {
return nil
}

View File

@@ -0,0 +1,316 @@
// 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
}

View File

@@ -0,0 +1,425 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package totp_test
import (
_ "embed"
"encoding/json"
"net/http"
"net/url"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/pquerna/otp"
stdtotp "github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
kratos "github.com/ory/kratos/pkg/httpclient"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/selfservice/strategy/totp"
"github.com/ory/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/assertx"
"github.com/ory/x/configx"
"github.com/ory/x/httprouterx"
"github.com/ory/x/sqlcon"
)
func TestCompleteSettings(t *testing.T) {
t.Parallel()
conf, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypePassword, false)),
configx.WithValues(testhelpers.MethodEnableConfig("profile", false)),
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypeTOTP, true)),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/settings.schema.json")),
configx.WithValues(map[string]any{
config.ViperKeySelfServiceSettingsRequiredAAL: "aal1",
config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter: "1m",
}),
)
router := httprouterx.NewRouterPublic()
publicTS, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, httprouterx.NewRouterAdminWithPrefix())
errTS := testhelpers.NewErrorTestServer(t, reg)
uiTS := testhelpers.NewSettingsUIFlowEchoServer(t, reg)
_ = testhelpers.NewRedirSessionEchoTS(t, reg)
loginTS := testhelpers.NewLoginUIFlowEchoServer(t, reg)
t.Run("case=device unlinking is available when identity has totp", func(t *testing.T) {
id, _, _ := createIdentity(t.Context(), t, reg)
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id)
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS)
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{
"0.attributes.value",
})
})
t.Run("case=device setup is available when identity has no totp yet", func(t *testing.T) {
id, _, _ := createIdentity(t.Context(), t, reg)
id.Credentials = nil
require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(t.Context(), id))
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id)
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS)
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{
"0.attributes.value",
"1.attributes.src",
"2.attributes.text.context.secret",
"2.attributes.text.text",
})
})
doAPIFlow := func(t *testing.T, v func(url.Values), id *identity.Identity) (string, *http.Response) {
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id)
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("method", "totp")
v(values)
payload := testhelpers.EncodeFormAsJSON(t, true, values)
return testhelpers.SettingsMakeRequest(t, true, false, f, apiClient, payload)
}
doBrowserFlow := func(t *testing.T, spa bool, v func(url.Values), id *identity.Identity) (string, *http.Response) {
browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id)
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, spa, publicTS)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set("method", "totp")
v(values)
return testhelpers.SettingsMakeRequest(t, false, spa, f, browserClient, testhelpers.EncodeFormAsJSON(t, spa, values))
}
t.Run("case=should pass without csrf if API flow", func(t *testing.T) {
id := createIdentityWithoutTOTP(t.Context(), t, reg)
body, res := doAPIFlow(t, func(v url.Values) {
v.Del("csrf_token")
v.Set(node.TOTPCode, "111111")
}, id)
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
assert.Equal(t, text.NewErrorValidationTOTPVerifierWrong().Text, gjson.Get(body, totpCodeGJSONQuery+".messages.0.text").String(), "%s", body)
})
t.Run("case=should fail if CSRF token is invalid", func(t *testing.T) {
id := createIdentityWithoutTOTP(t.Context(), t, reg)
t.Run("type=browser", func(t *testing.T) {
body, res := doBrowserFlow(t, false, func(v url.Values) {
v.Del("csrf_token")
v.Set(node.TOTPCode, "111111")
}, id)
assert.Contains(t, res.Request.URL.String(), errTS.URL)
assert.Equal(t, nosurfx.ErrInvalidCSRFToken().Reason(), gjson.Get(body, "reason").String(), body)
})
t.Run("type=spa", func(t *testing.T) {
body, res := doBrowserFlow(t, true, func(v url.Values) {
v.Del("csrf_token")
v.Set(node.TOTPCode, "111111")
}, id)
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
assert.Equal(t, nosurfx.ErrInvalidCSRFToken().Reason(), gjson.Get(body, "error.reason").String(), body)
})
})
t.Run("type=can not unlink without privileged session", func(t *testing.T) {
conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "1ns")
t.Cleanup(func() {
conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "5m")
})
id, _, key := createIdentity(t.Context(), t, reg)
payload := func(v url.Values) {
v.Set("totp_unlink", "true")
}
checkIdentity := func(t *testing.T) {
_, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypeTOTP, id.ID.String())
require.NoError(t, err)
assert.Equal(t, key.URL(), gjson.GetBytes(cred.Config, "totp_url").String())
}
t.Run("type=api", func(t *testing.T) {
actual, res := doAPIFlow(t, payload, id)
assert.Equal(t, http.StatusForbidden, res.StatusCode)
assert.Contains(t, gjson.Get(actual, "redirect_browser_to").String(), publicTS.URL+"/self-service/login/browser?refresh=true&return_to=")
assertx.EqualAsJSONExcept(t, settings.NewFlowNeedsReAuth(), json.RawMessage(actual), []string{"redirect_browser_to"})
checkIdentity(t)
})
t.Run("type=spa", func(t *testing.T) {
actual, res := doBrowserFlow(t, true, payload, id)
assert.Equal(t, http.StatusForbidden, res.StatusCode)
assert.Contains(t, gjson.Get(actual, "redirect_browser_to").String(), publicTS.URL+"/self-service/login/browser?refresh=true&return_to=")
assertx.EqualAsJSONExcept(t, settings.NewFlowNeedsReAuth(), json.RawMessage(actual), []string{"redirect_browser_to"})
checkIdentity(t)
})
t.Run("type=browser", func(t *testing.T) {
actual, res := doBrowserFlow(t, false, payload, id)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), loginTS.URL+"/login-ts")
assertx.EqualAsJSON(t, text.NewInfoLoginReAuth().Text, json.RawMessage(gjson.Get(actual, "ui.messages.0.text").Raw), actual)
checkIdentity(t)
})
})
t.Run("type=can not set up new totp device without privileged session", func(t *testing.T) {
conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "1ns")
t.Cleanup(func() {
conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "5m")
})
id := createIdentityWithoutTOTP(t.Context(), t, reg)
payload := func(v url.Values) {
v.Set(node.TOTPCode, "111111")
}
checkIdentity := func(t *testing.T) {
_, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypeTOTP, id.ID.String())
require.ErrorIs(t, err, sqlcon.ErrNoRows())
}
t.Run("type=api", func(t *testing.T) {
actual, res := doAPIFlow(t, payload, id)
assert.Equal(t, http.StatusForbidden, res.StatusCode)
assert.Contains(t, gjson.Get(actual, "redirect_browser_to").String(), publicTS.URL+"/self-service/login/browser?refresh=true&return_to=")
assertx.EqualAsJSONExcept(t, settings.NewFlowNeedsReAuth(), json.RawMessage(actual), []string{"redirect_browser_to"})
checkIdentity(t)
})
t.Run("type=spa", func(t *testing.T) {
actual, res := doBrowserFlow(t, true, payload, id)
assert.Equal(t, http.StatusForbidden, res.StatusCode)
assert.Contains(t, gjson.Get(actual, "redirect_browser_to").String(), publicTS.URL+"/self-service/login/browser?refresh=true&return_to=")
assertx.EqualAsJSONExcept(t, settings.NewFlowNeedsReAuth(), json.RawMessage(actual), []string{"redirect_browser_to"})
checkIdentity(t)
})
t.Run("type=browser", func(t *testing.T) {
actual, res := doBrowserFlow(t, false, payload, id)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), loginTS.URL+"/login-ts")
assertx.EqualAsJSON(t, text.NewInfoLoginReAuth().Text, json.RawMessage(gjson.Get(actual, "ui.messages.0.text").Raw), actual)
checkIdentity(t)
})
})
t.Run("type=unlink TOTP device", func(t *testing.T) {
payload := func(v url.Values) {
v.Set("totp_unlink", "true")
}
checkIdentity := func(t *testing.T, id *identity.Identity) {
_, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypeTOTP, id.ID.String())
require.ErrorIs(t, err, sqlcon.ErrNoRows())
}
t.Run("type=api", func(t *testing.T) {
id, _, _ := createIdentity(t.Context(), t, reg)
actual, res := doAPIFlow(t, payload, id)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), actual)
checkIdentity(t, id)
assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual)
})
t.Run("type=spa", func(t *testing.T) {
id, _, _ := createIdentity(t.Context(), t, reg)
actual, res := doBrowserFlow(t, true, payload, id)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), actual)
checkIdentity(t, id)
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(actual, "continue_with.0.action").String(), "%s", actual)
assert.Contains(t, gjson.Get(actual, "continue_with.0.redirect_browser_to").String(), uiTS.URL, "%s", actual)
})
t.Run("type=browser", func(t *testing.T) {
id, _, _ := createIdentity(t.Context(), t, reg)
actual, res := doBrowserFlow(t, false, payload, id)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), uiTS.URL)
assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), actual)
checkIdentity(t, id)
assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual)
})
})
t.Run("type=set up TOTP device but code is incorrect", func(t *testing.T) {
payload := func(v url.Values) {
v.Set(node.TOTPCode, "111111")
}
checkIdentity := func(t *testing.T, id *identity.Identity) {
_, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypeTOTP, id.ID.String())
require.ErrorIs(t, err, sqlcon.ErrNoRows())
}
t.Run("type=api", func(t *testing.T) {
id := createIdentityWithoutTOTP(t.Context(), t, reg)
actual, res := doAPIFlow(t, payload, id)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
assert.Equal(t, text.NewErrorValidationTOTPVerifierWrong().Text, gjson.Get(actual, totpCodeGJSONQuery+".messages.0.text").String(), actual)
checkIdentity(t, id)
})
t.Run("type=spa", func(t *testing.T) {
id := createIdentityWithoutTOTP(t.Context(), t, reg)
actual, res := doBrowserFlow(t, true, payload, id)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
assert.Equal(t, text.NewErrorValidationTOTPVerifierWrong().Text, gjson.Get(actual, totpCodeGJSONQuery+".messages.0.text").String(), actual)
checkIdentity(t, id)
})
t.Run("type=browser", func(t *testing.T) {
id := createIdentityWithoutTOTP(t.Context(), t, reg)
actual, res := doBrowserFlow(t, false, payload, id)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), uiTS.URL)
assert.Equal(t, text.NewErrorValidationTOTPVerifierWrong().Text, gjson.Get(actual, totpCodeGJSONQuery+".messages.0.text").String(), actual)
checkIdentity(t, id)
})
})
t.Run("type=set up TOTP device", func(t *testing.T) {
checkIdentity := func(t *testing.T, id *identity.Identity, key string) {
i, cred, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypeTOTP, id.ID.String())
require.NoError(t, err)
var c identity.CredentialsTOTPConfig
require.NoError(t, json.Unmarshal(cred.Config, &c))
actual, err := otp.NewKeyFromURL(c.TOTPURL)
require.NoError(t, err)
assert.Equal(t, key, actual.Secret())
assert.Contains(t, c.TOTPURL, gjson.GetBytes(i.Traits, "subject").String())
}
run := func(t *testing.T, isAPI, isSPA bool, id *identity.Identity, hc *http.Client, f *kratos.SettingsFlow) {
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
nodes, err := json.Marshal(f.Ui.Nodes)
require.NoError(t, err)
key := gjson.GetBytes(nodes, "#(attributes.id==totp_secret_key).attributes.text.context.secret").String()
require.NotEmpty(t, key, nodes)
code, err := stdtotp.GenerateCode(key, time.Now())
require.NoError(t, err)
values.Set("method", "totp")
values.Set(node.TOTPCode, code)
actual, res := testhelpers.SettingsMakeRequest(t, isAPI, isSPA, f, hc, testhelpers.EncodeFormAsJSON(t, isAPI || isSPA, values))
require.NotEmpty(t, key)
assert.Equal(t, http.StatusOK, res.StatusCode)
if isAPI || isSPA {
assert.Contains(t, res.Request.URL.String(), publicTS.URL+settings.RouteSubmitFlow)
assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), actual)
} else {
assert.Contains(t, res.Request.URL.String(), uiTS.URL)
assert.EqualValues(t, flow.StateSuccess, gjson.Get(actual, "state").String(), actual)
}
actualFlow, err := reg.SettingsFlowPersister().GetSettingsFlow(t.Context(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
assert.Empty(t, gjson.GetBytes(actualFlow.InternalContext, flow.PrefixInternalContextKey(identity.CredentialsTypeTOTP, totp.InternalContextKeyURL)))
checkIdentity(t, id, key)
testhelpers.EnsureAAL(t, hc, publicTS, "aal2", string(identity.CredentialsTypeTOTP))
if isSPA {
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(actual, "continue_with.0.action").String(), "%s", actual)
assert.Contains(t, gjson.Get(actual, "continue_with.0.redirect_browser_to").String(), uiTS.URL, "%s", actual)
} else {
assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual)
}
}
t.Run("type=api", func(t *testing.T) {
id := createIdentityWithoutTOTP(t.Context(), t, reg)
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, reg, id)
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, publicTS)
run(t, true, false, id, apiClient, f)
})
t.Run("type=spa", func(t *testing.T) {
id := createIdentityWithoutTOTP(t.Context(), t, reg)
user := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id)
f := testhelpers.InitializeSettingsFlowViaBrowser(t, user, true, publicTS)
run(t, false, true, id, user, f)
})
t.Run("type=browser", func(t *testing.T) {
id := createIdentityWithoutTOTP(t.Context(), t, reg)
user := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, reg, id)
f := testhelpers.InitializeSettingsFlowViaBrowser(t, user, false, publicTS)
run(t, false, false, id, user, f)
})
t.Run("type=refreshes authenticated_at of the enrolling session", func(t *testing.T) {
// Pin the privileged window explicitly so this subtest does not
// depend on sibling tests' config mutations. The session is
// backdated but stays well inside the 5m window, while the
// refreshed timestamp is still observable.
conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "5m")
t.Cleanup(func() {
conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "1m")
})
id := createIdentityWithoutTOTP(t.Context(), t, reg)
staleAuthenticatedAt := time.Now().Add(-30 * time.Second).UTC()
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
sess, err := testhelpers.NewActiveSession(req, reg, id, staleAuthenticatedAt,
identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
browserClient := testhelpers.NewHTTPClientWithSessionCookie(t.Context(), t, reg, sess)
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, false, publicTS)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
nodes, err := json.Marshal(f.Ui.Nodes)
require.NoError(t, err)
key := gjson.GetBytes(nodes, "#(attributes.id==totp_secret_key).attributes.text.context.secret").String()
require.NotEmpty(t, key, "%s", nodes)
code, err := stdtotp.GenerateCode(key, time.Now())
require.NoError(t, err)
values.Set("method", "totp")
values.Set(node.TOTPCode, code)
_, res := testhelpers.SettingsMakeRequest(t, false, false, f, browserClient, testhelpers.EncodeFormAsJSON(t, false, values))
require.Equal(t, http.StatusOK, res.StatusCode)
actual, err := reg.SessionPersister().GetSession(t.Context(), sess.ID, session.ExpandNothing)
require.NoError(t, err)
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, actual.AuthenticatorAssuranceLevel)
assert.True(t, actual.AuthenticatedAt.After(staleAuthenticatedAt.Add(10*time.Second)),
"authenticated_at must be refreshed by TOTP enrollment; got %s, seeded %s", actual.AuthenticatedAt, staleAuthenticatedAt)
})
})
}

View File

@@ -0,0 +1,126 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package totp
import (
"context"
"encoding/json"
"net/http"
"github.com/ory/kratos/x/nosurfx"
"github.com/pkg/errors"
"github.com/pquerna/otp"
"github.com/ory/kratos/continuity"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hash"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/errorx"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/session"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
)
var (
_ login.Strategy = (*Strategy)(nil)
_ login.AAL2FormHydrator = (*Strategy)(nil)
_ settings.Strategy = (*Strategy)(nil)
_ identity.ActiveCredentialsCounter = (*Strategy)(nil)
)
type dependencies interface {
logrusx.Provider
httpx.WriterProvider
nosurfx.CSRFTokenGeneratorProvider
nosurfx.CSRFProvider
otelx.Provider
config.Provider
continuity.ManagementProvider
x.CookieProvider
errorx.ManagementProvider
hash.HashProvider
registration.HandlerProvider
registration.HooksProvider
registration.ErrorHandlerProvider
registration.HookExecutorProvider
registration.FlowPersistenceProvider
login.HooksProvider
login.ErrorHandlerProvider
login.HookExecutorProvider
login.FlowPersistenceProvider
login.HandlerProvider
settings.FlowPersistenceProvider
settings.HookExecutorProvider
settings.HooksProvider
settings.ErrorHandlerProvider
identity.PrivilegedPoolProvider
identity.ValidationProvider
session.HandlerProvider
session.ManagementProvider
session.PersistenceProvider
}
type Strategy struct{ d dependencies }
func NewStrategy(d dependencies) *Strategy { return &Strategy{d: d} }
func (s *Strategy) CountActiveFirstFactorCredentials(_ context.Context, _ map[identity.CredentialsType]identity.Credentials) (count int, err error) {
return 0, nil
}
func (s *Strategy) CountActiveMultiFactorCredentials(_ context.Context, cc map[identity.CredentialsType]identity.Credentials) (count int, err error) {
for _, c := range cc {
if c.Type == s.ID() && len(c.Config) > 0 {
var conf identity.CredentialsTOTPConfig
if err = json.Unmarshal(c.Config, &conf); err != nil {
return 0, errors.WithStack(err)
}
_, err := otp.NewKeyFromURL(conf.TOTPURL)
if len(conf.TOTPURL) > 0 && err == nil {
count++
}
}
}
return
}
func (s *Strategy) ID() identity.CredentialsType {
return identity.CredentialsTypeTOTP
}
func (s *Strategy) NodeGroup() node.UiNodeGroup {
return node.TOTPGroup
}
func (s *Strategy) CompletedAuthenticationMethod(ctx context.Context) session.AuthenticationMethod {
return session.AuthenticationMethod{
Method: s.ID(),
AAL: identity.AuthenticatorAssuranceLevel2,
}
}
func (s *Strategy) PopulateLoginMethodSecondFactor(r *http.Request, f *login.Flow) error {
return s.PopulateLoginMethod(r, identity.AuthenticatorAssuranceLevel2, f)
}
func (s *Strategy) PopulateLoginMethodSecondFactorRefresh(r *http.Request, f *login.Flow) error {
return s.PopulateLoginMethod(r, identity.AuthenticatorAssuranceLevel2, f)
}

View File

@@ -0,0 +1,82 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package totp_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/selfservice/strategy/totp"
)
func TestCountActiveCredentials(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
strategy := totp.NewStrategy(reg)
key, err := totp.NewKey(t.Context(), "foo", reg)
require.NoError(t, err)
t.Run("first factor", func(t *testing.T) {
actual, err := strategy.CountActiveFirstFactorCredentials(t.Context(), nil)
require.NoError(t, err)
assert.Equal(t, 0, actual)
})
t.Run("multi factor", func(t *testing.T) {
for k, tc := range []struct {
in map[identity.CredentialsType]identity.Credentials
expected int
}{
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Config: []byte{},
}},
expected: 0,
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Config: []byte(`{"totp_url": ""}`),
}},
expected: 0,
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Identifiers: []string{"foo"},
Config: []byte(`{"totp_url": "` + key.URL() + `"}`),
}},
expected: 1,
},
{
in: map[identity.CredentialsType]identity.Credentials{strategy.ID(): {
Type: strategy.ID(),
Config: []byte(`{}`),
}},
expected: 0,
},
{
in: nil,
expected: 0,
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
cc := map[identity.CredentialsType]identity.Credentials{}
for _, c := range tc.in {
cc[c.Type] = c
}
actual, err := strategy.CountActiveMultiFactorCredentials(t.Context(), cc)
require.NoError(t, err)
assert.Equal(t, tc.expected, actual)
})
}
})
}

View File

@@ -0,0 +1,11 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"subject": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"totp": {
"account_name": true
}
}
}
}
}
}
}
}