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