// 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) }) }) }