snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
650
selfservice/strategy/totp/login_test.go
Normal file
650
selfservice/strategy/totp/login_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user